soliplex 0.83.4__tar.gz → 0.85__tar.gz

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 (125) hide show
  1. {soliplex-0.83.4/src/soliplex.egg-info → soliplex-0.85}/PKG-INFO +5 -4
  2. {soliplex-0.83.4 → soliplex-0.85}/pyproject.toml +5 -4
  3. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agents.py +7 -0
  4. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/installation.py +7 -5
  5. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/routing.py +15 -2
  6. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/skills.py +70 -25
  7. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/loggers.py +34 -48
  8. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/models.py +1 -2
  9. soliplex-0.85/src/soliplex/sandbox_audit.py +145 -0
  10. soliplex-0.85/src/soliplex/skills/bwrap_sandbox/SKILL.md +79 -0
  11. soliplex-0.85/src/soliplex/skills/bwrap_sandbox/__init__.py +1112 -0
  12. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/sandbox_workdirs.py +32 -28
  13. {soliplex-0.83.4 → soliplex-0.85/src/soliplex.egg-info}/PKG-INFO +5 -4
  14. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex.egg-info/requires.txt +4 -3
  15. soliplex-0.83.4/src/soliplex/sandbox_audit.py +0 -164
  16. soliplex-0.83.4/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -219
  17. soliplex-0.83.4/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -858
  18. {soliplex-0.83.4 → soliplex-0.85}/LICENSE +0 -0
  19. {soliplex-0.83.4 → soliplex-0.85}/MANIFEST.in +0 -0
  20. {soliplex-0.83.4 → soliplex-0.85}/README.md +0 -0
  21. {soliplex-0.83.4 → soliplex-0.85}/example/skills/bare-bones/SKILL.md +0 -0
  22. {soliplex-0.83.4 → soliplex-0.85}/example/skills/stanzas/SKILL.md +0 -0
  23. {soliplex-0.83.4 → soliplex-0.85}/setup.cfg +0 -0
  24. {soliplex-0.83.4 → soliplex-0.85}/skills/soliplex-docs/SKILL.md +0 -0
  25. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agui/__init__.py +0 -0
  26. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agui/parser.py +0 -0
  27. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agui/persistence.py +0 -0
  28. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agui/schema.py +0 -0
  29. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/agui/util.py +0 -0
  30. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/__init__.py +0 -0
  31. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/env.py +0 -0
  32. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/216d48e1e2e5_soliplex_v0_53.py +0 -0
  33. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/4b63e5f3f39e_soliplex_v0_51_1.py +0 -0
  34. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/63edaa5987f6_soliplex_v0_67.py +0 -0
  35. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/a1c7d3e90b42_soliplex_v0_80.py +0 -0
  36. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/b7e2f41c9d05_soliplex_v0_82.py +0 -0
  37. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/alembic_migrations/versions/d5009d4f9874_soliplex_v0_44.py +0 -0
  38. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/authn.py +0 -0
  39. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/authz/__init__.py +0 -0
  40. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/authz/persistence.py +0 -0
  41. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/authz/schema.py +0 -0
  42. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/capabilities/filesystem.py +0 -0
  43. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/capabilities/rag_audit.py +0 -0
  44. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/admin_users.py +0 -0
  45. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/ask.py +0 -0
  46. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/__init__.py +0 -0
  47. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/_common.py +0 -0
  48. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/admin_users.py +0 -0
  49. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/completions.py +0 -0
  50. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/databases.py +0 -0
  51. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/environment.py +0 -0
  52. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/installation.py +0 -0
  53. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/interpolation.py +0 -0
  54. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/logfire.py +0 -0
  55. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/logging.py +0 -0
  56. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/oidc.py +0 -0
  57. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/ollama.py +0 -0
  58. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/quizzes.py +0 -0
  59. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/room_authz.py +0 -0
  60. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/rooms.py +0 -0
  61. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/secrets.py +0 -0
  62. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/audit/skills.py +0 -0
  63. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/cli_util.py +0 -0
  64. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/database.py +0 -0
  65. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/main.py +0 -0
  66. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/ollama.py +0 -0
  67. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/room_authz.py +0 -0
  68. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/serve.py +0 -0
  69. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/cli/types.py +0 -0
  70. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/completions.py +0 -0
  71. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/__init__.py +0 -0
  72. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/_utils.py +0 -0
  73. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/agents.py +0 -0
  74. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/agui.py +0 -0
  75. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/authsystem.py +0 -0
  76. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/completions.py +0 -0
  77. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/exceptions.py +0 -0
  78. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/interpolation.py +0 -0
  79. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/logfire.py +0 -0
  80. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/meta.py +0 -0
  81. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/middleware.py +0 -0
  82. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/quizzes.py +0 -0
  83. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/rag.py +0 -0
  84. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/rooms.py +0 -0
  85. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/secrets.py +0 -0
  86. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/config/tools.py +0 -0
  87. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/examples.py +0 -0
  88. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/installation.py +0 -0
  89. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/log_ingest.py +0 -0
  90. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/main.py +0 -0
  91. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/mcp_auth.py +0 -0
  92. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/mcp_client.py +0 -0
  93. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/mcp_server.py +0 -0
  94. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/ollama.py +0 -0
  95. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/quizzes.py +0 -0
  96. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/rag_audit.py +0 -0
  97. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/secrets.py +0 -0
  98. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/titles.py +0 -0
  99. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tools/__init__.py +0 -0
  100. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tools/agui_run_feedback.py +0 -0
  101. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tools/file_uploads.py +0 -0
  102. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tools/rag.py +0 -0
  103. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tui/cli.py +0 -0
  104. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tui/main.py +0 -0
  105. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tui/rest_api.py +0 -0
  106. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/tui/serve.py +0 -0
  107. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/util.py +0 -0
  108. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/__init__.py +0 -0
  109. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/agui.py +0 -0
  110. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/authn.py +0 -0
  111. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/authz.py +0 -0
  112. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/completions.py +0 -0
  113. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/installation.py +0 -0
  114. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/log_ingest.py +0 -0
  115. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/quizzes.py +0 -0
  116. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/room_file_uploads.py +0 -0
  117. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/rooms.py +0 -0
  118. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/stats.py +0 -0
  119. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/streaming.py +0 -0
  120. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/templates/consent.html.mako +0 -0
  121. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex/views/thread_file_uploads.py +0 -0
  122. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex.egg-info/SOURCES.txt +0 -0
  123. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex.egg-info/dependency_links.txt +0 -0
  124. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex.egg-info/entry_points.txt +0 -0
  125. {soliplex-0.83.4 → soliplex-0.85}/src/soliplex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex
3
- Version: 0.83.4
3
+ Version: 0.85
4
4
  Summary: An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
5
5
  Author-email: Enfold <info@enfoldsystems.net>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -13,13 +13,14 @@ Requires-Dist: aiohttp<3.15,>=3.14.2
13
13
  Requires-Dist: aiosqlite
14
14
  Requires-Dist: alembic<1.19,>=1.18.4
15
15
  Requires-Dist: authlib
16
- Requires-Dist: bubble-sandbox<0.13,>=0.12.0
16
+ Requires-Dist: bubble-sandbox<0.14,>=0.13.0
17
+ Requires-Dist: packaging
17
18
  Requires-Dist: cryptography>=50.0.0
18
19
  Requires-Dist: fastapi
19
20
  Requires-Dist: fastmcp<5.0,>=4.0.2
20
21
  Requires-Dist: fakeredis!=2.35.0
21
22
  Requires-Dist: greenlet
22
- Requires-Dist: haiku.rag-slim<0.88,>=0.87.0
23
+ Requires-Dist: haiku.rag-slim<0.96,>=0.95.0
23
24
  Requires-Dist: idna>=3.15
24
25
  Requires-Dist: itsdangerous
25
26
  Requires-Dist: joserfc<1.7,>=1.6.7
@@ -33,7 +34,7 @@ Requires-Dist: logfire[fastapi]
33
34
  Requires-Dist: pyasn1>=0.6.4
34
35
  Requires-Dist: pydantic-ai-slim[google]<3.0,>=2.40.0
35
36
  Requires-Dist: pydantic-settings<2.15,>=2.14.2
36
- Requires-Dist: pyjwt<2.14,>=2.13.0
37
+ Requires-Dist: pyjwt<2.16,>=2.15.0
37
38
  Requires-Dist: pymdown-extensions>=11.0.0
38
39
  Requires-Dist: python-keycloak
39
40
  Requires-Dist: skills-ref
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.83.4"
7
+ version = "0.85"
8
8
  description = "An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface."
9
9
  authors = [{ name = "Enfold", email = "info@enfoldsystems.net" }]
10
10
  readme = "README.md"
@@ -19,13 +19,14 @@ dependencies = [
19
19
  "aiosqlite",
20
20
  "alembic >= 1.18.4, < 1.19",
21
21
  "authlib",
22
- "bubble-sandbox >= 0.12.0, < 0.13",
22
+ "bubble-sandbox >= 0.13.0, < 0.14",
23
+ "packaging",
23
24
  "cryptography >= 50.0.0", # PYSEC-2026-3552
24
25
  "fastapi",
25
26
  "fastmcp >= 4.0.2, < 5.0",
26
27
  "fakeredis != 2.35.0", # brownbag
27
28
  "greenlet",
28
- "haiku.rag-slim >= 0.87.0, < 0.88",
29
+ "haiku.rag-slim >= 0.95.0, < 0.96",
29
30
  "idna >= 3.15",
30
31
  "itsdangerous",
31
32
  "joserfc >= 1.6.7, < 1.7", # CVE-2026-48990
@@ -39,7 +40,7 @@ dependencies = [
39
40
  "pyasn1 >= 0.6.4", # PYSEC-2026-3456
40
41
  "pydantic-ai-slim[google] >= 2.40.0, < 3.0",
41
42
  "pydantic-settings >= 2.14.2, < 2.15", # GHSA-4xgf-cpjx-pc3j
42
- "pyjwt >= 2.13.0, < 2.14", # PYSEC-2026-{175,177,178,179}
43
+ "pyjwt >= 2.15.0, < 2.16", # CVE-2026-102275
43
44
  "pymdown-extensions >= 11.0.0", # CVE-2026-61632
44
45
  "python-keycloak",
45
46
  "skills-ref",
@@ -17,6 +17,7 @@ from soliplex import models
17
17
  from soliplex.capabilities import rag_audit as cap_rag_audit
18
18
  from soliplex.config import agents as config_agents
19
19
  from soliplex.config import tools as config_tools
20
+ from soliplex.skills import bwrap_sandbox
20
21
 
21
22
  ToolConfigMap = dict[str, typing.Any]
22
23
 
@@ -138,6 +139,12 @@ def get_default_agent_from_configs(
138
139
  if isinstance(capability, hr_caps_rag.RAGCapability):
139
140
  capability.vision = agent_config.multimodal
140
141
 
142
+ # 'read_image' returns an image to the model, so a room whose model
143
+ # cannot accept one should not see the tool at all.
144
+ for capability in capabilities:
145
+ if isinstance(capability, bwrap_sandbox.SandboxCapability):
146
+ capability.multimodal = agent_config.multimodal
147
+
141
148
  # A hook-only capability is never deferred: nothing would load it, and
142
149
  # its hooks only fire once loaded.
143
150
  for capability in capabilities:
@@ -315,13 +315,15 @@ class SandboxConfig:
315
315
  return None
316
316
 
317
317
  @property
318
- def workdirs_path(self) -> pathlib.Path:
319
- """Directory holding "workdirs" for each run
318
+ def workdirs_path(self) -> pathlib.Path | None:
319
+ """Directory holding each thread's sandbox workspace
320
320
 
321
- A workdir will be named with the run ID, with parent directories
322
- for the room ID and thread ID.
321
+ Named with the thread ID, under a directory for the room ID. The
322
+ workspace persists across the thread's turns, and the workdirs
323
+ endpoint serves the files at its top level.
323
324
 
324
- If not set, the sandox workdir will be a temporary directory.
325
+ If not set, each command gets a temporary directory instead, which
326
+ is discarded when it ends and which the endpoint cannot serve.
325
327
  """
326
328
  if self._config_path is not None and self._workdirs_path is not None:
327
329
  return (self._config_path.parent / self._workdirs_path).resolve()
@@ -5,6 +5,7 @@ import pathlib
5
5
  import typing
6
6
 
7
7
  import fastapi
8
+ from bubble_sandbox import sandbox as bs_sandbox
8
9
 
9
10
  from . import _utils
10
11
  from . import exceptions as config_exc
@@ -230,7 +231,6 @@ _DEFAULT_ROUTER_NAMES = {
230
231
  "completions": "soliplex.views.completions.router",
231
232
  "room_file_uploads": "soliplex.views.room_file_uploads.router",
232
233
  "thread_file_uploads": "soliplex.views.thread_file_uploads.router",
233
- "sandbox_workdirs": "soliplex.views.sandbox_workdirs.router",
234
234
  "installation": "soliplex.views.installation.router",
235
235
  "log_ingest": "soliplex.views.log_ingest.router",
236
236
  "quizzes": "soliplex.views.quizzes.router",
@@ -239,8 +239,21 @@ _DEFAULT_ROUTER_NAMES = {
239
239
  }
240
240
 
241
241
 
242
+ # Routers serving what the bubblewrap sandbox leaves behind, mounted only
243
+ # where 'bwrap' can run.
244
+ _BWRAP_ROUTER_NAMES = {
245
+ "sandbox_workdirs": "soliplex.views.sandbox_workdirs.router",
246
+ }
247
+
248
+
249
+ def _default_router_names() -> dict[str, str]:
250
+ if bs_sandbox.is_available():
251
+ return _DEFAULT_ROUTER_NAMES | _BWRAP_ROUTER_NAMES
252
+ return dict(_DEFAULT_ROUTER_NAMES)
253
+
254
+
242
255
  def register_default_routers():
243
- for group_name, router_name in _DEFAULT_ROUTER_NAMES.items():
256
+ for group_name, router_name in _default_router_names().items():
244
257
  router = _utils._from_dotted_name(router_name)
245
258
  APP_ROUTERS_BY_GROUP_NAME[group_name] = (
246
259
  router,
@@ -10,6 +10,7 @@ import typing
10
10
  import pydantic
11
11
  from bubble_sandbox import config as bs_config
12
12
  from bubble_sandbox import models as bs_models
13
+ from bubble_sandbox import sandbox as bs_sandbox
13
14
  from haiku.rag.capabilities import compaction as hr_compaction
14
15
  from haiku.rag.capabilities import policy as hr_policy
15
16
  from haiku.rag.capabilities import rag as hr_rag
@@ -31,6 +32,11 @@ _default_list_field = _utils._default_list_field
31
32
  _no_repr_no_compare_none = _utils._no_repr_no_compare_none
32
33
 
33
34
 
35
+ # Applied when the room sets no limit.
36
+ DEFAULT_EXECUTION_TIMEOUT_SECONDS = 30.0
37
+ DEFAULT_MAX_OUTPUT_CHARS = 10_000
38
+
39
+
34
40
  class InvalidSkillKind(KeyError):
35
41
  def __init__(
36
42
  self,
@@ -49,6 +55,23 @@ class InvalidSkillKind(KeyError):
49
55
  )
50
56
 
51
57
 
58
+ class BwrapSandboxUnavailable(RuntimeError):
59
+ def __init__(
60
+ self,
61
+ *,
62
+ skill_kind: str,
63
+ reason: bs_sandbox.SandboxUnavailable,
64
+ _config_path: pathlib.Path | None,
65
+ ):
66
+ self.skill_kind = skill_kind
67
+ self.reason = reason
68
+ self._config_path = _config_path
69
+ super().__init__(
70
+ f"Skill kind '{skill_kind}' cannot run on this host: {reason} "
71
+ f"(configured in {_config_path})",
72
+ )
73
+
74
+
52
75
  class MissingSkillNames(KeyError):
53
76
  def __init__(
54
77
  self,
@@ -369,9 +392,10 @@ class BwrapSandboxSkillConfig:
369
392
  _config_path: pathlib.Path | None = None
370
393
 
371
394
  id: str | None = None
372
- default_environment: str = "bare"
373
- allowed_environments: bwrap_sandbox.AllowedEnvironments = None
374
- sandbox_config: bs_config.Config = None
395
+ environment: str = "bare"
396
+ # Unset means "whatever the default is", not its value today.
397
+ execution_timeout_seconds: float | None = None
398
+ max_output_chars: int | None = None
375
399
  volumes: bs_models.VolumeMap = _default_dict_field()
376
400
  defer_loading: bool = False
377
401
 
@@ -386,10 +410,6 @@ class BwrapSandboxSkillConfig:
386
410
  config_dict.pop("kind", None)
387
411
  config_dict["_installation_config"] = installation_config
388
412
  config_dict["_config_path"] = config_path
389
- config_dict["sandbox_config"] = bs_config.Config(
390
- config_file_path=config_path,
391
- **config_dict.pop("sandbox_config", {}),
392
- )
393
413
  config_dict["volumes"] = {
394
414
  key: bs_models.VolumeInfo(**value)
395
415
  for key, value in config_dict.pop("volumes", {}).items()
@@ -402,12 +422,45 @@ class BwrapSandboxSkillConfig:
402
422
  config_dict,
403
423
  ) from exc
404
424
 
425
+ @property
426
+ def sandbox_config(self) -> bs_config.Config:
427
+ timeout = self.execution_timeout_seconds
428
+ max_chars = self.max_output_chars
429
+ return bs_config.Config(
430
+ config_file_path=self._config_path,
431
+ execution_timeout_seconds=(
432
+ timeout
433
+ if timeout is not None
434
+ else DEFAULT_EXECUTION_TIMEOUT_SECONDS
435
+ ),
436
+ max_output_chars=(
437
+ max_chars
438
+ if max_chars is not None
439
+ else DEFAULT_MAX_OUTPUT_CHARS
440
+ ),
441
+ )
442
+
405
443
  @property
406
444
  def capability(self) -> bwrap_sandbox.SandboxCapability:
445
+ """Build the sandbox capability.
446
+
447
+ Raises:
448
+
449
+ - 'BwrapSandboxUnavailable' on a host where 'bwrap' cannot run,
450
+ as reported by 'bubble_sandbox.sandbox.check_available'.
451
+ """
452
+ try:
453
+ bs_sandbox.check_available()
454
+ except bs_sandbox.SandboxUnavailable as exc:
455
+ raise BwrapSandboxUnavailable(
456
+ skill_kind=self.kind,
457
+ reason=exc,
458
+ _config_path=self._config_path,
459
+ ) from exc
460
+
407
461
  return bwrap_sandbox.create_bwrap_sandbox_capability(
408
462
  id=self.id,
409
- default_environment=self.default_environment,
410
- allowed_environments=self.allowed_environments,
463
+ environment=self.environment,
411
464
  sandbox_config=self.sandbox_config,
412
465
  volumes=self.volumes,
413
466
  installation_config=self._installation_config,
@@ -418,22 +471,17 @@ class BwrapSandboxSkillConfig:
418
471
  def as_yaml(self) -> dict:
419
472
  result = {
420
473
  "kind": self.kind,
421
- "default_environment": self.default_environment,
474
+ "environment": self.environment,
422
475
  "defer_loading": self.defer_loading,
423
476
  }
424
477
  if self.id is not None:
425
478
  result["id"] = self.id
426
- if self.allowed_environments is not None:
427
- result["allowed_environments"] = self.allowed_environments
428
- if self.sandbox_config is not None:
429
- config = self.sandbox_config
430
- result["sandbox_config"] = {
431
- "environments_pathname": config.environments_pathname,
432
- "execution_timeout_seconds": (
433
- config.execution_timeout_seconds
434
- ),
435
- "max_output_chars": config.max_output_chars,
436
- }
479
+ if self.execution_timeout_seconds is not None:
480
+ result["execution_timeout_seconds"] = (
481
+ self.execution_timeout_seconds
482
+ )
483
+ if self.max_output_chars is not None:
484
+ result["max_output_chars"] = self.max_output_chars
437
485
  if self.volumes:
438
486
  result["volumes"] = {
439
487
  key: {
@@ -446,10 +494,7 @@ class BwrapSandboxSkillConfig:
446
494
 
447
495
  @property
448
496
  def extra_parameters(self) -> dict[str, typing.Any]:
449
- result = {"default_environment": self.default_environment}
450
- if self.allowed_environments is not None:
451
- result["allowed_environments"] = self.allowed_environments
452
- return result
497
+ return {"environment": self.environment}
453
498
 
454
499
 
455
500
  @dataclasses.dataclass(kw_only=True)
@@ -42,8 +42,8 @@ UPLOADS_GET_ROOM_THREAD_FILE = "uploads get room thread file"
42
42
  UPLOADS_POST_ROOM = "uploads post room"
43
43
  UPLOADS_POST_ROOM_THREAD = "uploads post room thread"
44
44
 
45
- WORKDIRS_GET_ROOM_THREAD_RUN = "workdirs get room thread run"
46
- WORKDIRS_GET_ROOM_THREAD_RUN_FILE = "workdirs get room thread run file"
45
+ WORKDIRS_GET_ROOM_THREAD = "workdirs get room thread"
46
+ WORKDIRS_GET_ROOM_THREAD_FILE = "workdirs get room thread file"
47
47
 
48
48
  AUTHN_BAD_REQUEST = "authn bad request"
49
49
  AUTHN_CONSENT_CANCELLED = "unlisted frontend origin: sign-in cancelled"
@@ -186,22 +186,20 @@ AUDIT_RAG_ACTION_CHUNK_VIZ = "chunk-viz"
186
186
  AUDIT_RAG_ACTION_DOC_LIST = "doc-list"
187
187
 
188
188
  # sandbox-exec audit events (data change): the room agent's sandbox skill
189
- # executes code against a per-run, writable working directory.
189
+ # executes code against the thread's writable working directory, or reads
190
+ # an image out of a mount.
190
191
  AUDIT_SANDBOX_EXEC = "sandbox exec"
191
192
 
192
- # sandbox-exec 'action' values: which tool drove the execution.
193
+ # sandbox-exec 'action' values: which tool made the record.
193
194
  AUDIT_SANDBOX_ACTION_RUN = "run"
194
195
  AUDIT_SANDBOX_ACTION_RUN_PYTHON = "run-python"
196
+ AUDIT_SANDBOX_ACTION_READ_IMAGE = "read-image"
195
197
 
196
198
  # sandbox-exec 'reason' values for a non-zero exit: the sandbox cut the
197
199
  # execution off, or the code itself ended badly.
198
200
  AUDIT_SANDBOX_REASON_TIMEOUT = "timeout"
199
201
  AUDIT_SANDBOX_REASON_EXIT_CODE = "exit-code"
200
202
 
201
- # sandbox volume-list audit events (disclosure): the sandbox skill tells
202
- # the agent which uploaded files a volume holds.
203
- AUDIT_SANDBOX_VOLUME_LIST = "sandbox volume list"
204
-
205
203
  # room-upload audit events: an admin (privileged) adds shared reference
206
204
  # material to a room, changing what the room's agent and members can access.
207
205
  AUDIT_ROOM_UPLOAD_ADDED = "room upload added"
@@ -651,25 +649,11 @@ class RAGAccessAuditLog(AuditLogWrapper):
651
649
 
652
650
 
653
651
  class SandboxExecAuditLog(AuditLogWrapper):
654
- """Record the sandbox skill's executions and volume listings.
655
-
656
- Each ``run`` / ``run_python`` invocation is one data-change event: the
657
- room agent's sandbox skill executes code against the run's writable
658
- working directory. The command and script bodies are deliberately not
659
- recorded inline (size / content leakage); the record captures the
660
- 'action' (which tool ran), the 'workdir' whose data the execution may
661
- have changed, the 'environment' it ran in, 'refs' -- the host paths of
662
- the saved command / script transcripts (empty when transcripts are not
663
- configured) -- and the 'exit_code' the execution ended with.
664
-
665
- An execution refused for naming an unavailable environment is recorded
666
- as 'denied': nothing ran, so it carries no exit code, and its 'workdir'
667
- is None because none was created.
668
-
669
- ``list_volume_files`` discloses the names of a volume's uploaded files
670
- to the agent, so it is recorded too, as its own event carrying the
671
- 'volume' and the 'count' of files disclosed. The names themselves stay
672
- out of the record, for the same reason the bodies do.
652
+ """Record the sandbox skill's executions and image reads.
653
+
654
+ A record names the 'action', the 'workdir' the execution could change,
655
+ the 'environment' it ran in, 'refs' for the saved transcripts, and how
656
+ it ended. Command and script bodies are never recorded inline.
673
657
  """
674
658
 
675
659
  def __init__(self, claims: dict[str, typing.Any], **extra):
@@ -686,6 +670,8 @@ class SandboxExecAuditLog(AuditLogWrapper):
686
670
  refs: typing.Any,
687
671
  *,
688
672
  exit_code: int | None = None,
673
+ truncated: bool = False,
674
+ timeout_seconds: float | None = None,
689
675
  ):
690
676
  self._succeeded(
691
677
  AUDIT_SANDBOX_EXEC,
@@ -694,6 +680,8 @@ class SandboxExecAuditLog(AuditLogWrapper):
694
680
  environment=environment,
695
681
  refs=refs,
696
682
  exit_code=exit_code,
683
+ truncated=truncated,
684
+ timeout_seconds=timeout_seconds,
697
685
  )
698
686
 
699
687
  def execute_failed(
@@ -705,6 +693,8 @@ class SandboxExecAuditLog(AuditLogWrapper):
705
693
  reason: str,
706
694
  *,
707
695
  exit_code: int | None = None,
696
+ truncated: bool = False,
697
+ timeout_seconds: float | None = None,
708
698
  ):
709
699
  self._failed(
710
700
  AUDIT_SANDBOX_EXEC,
@@ -714,36 +704,32 @@ class SandboxExecAuditLog(AuditLogWrapper):
714
704
  refs=refs,
715
705
  reason=reason,
716
706
  exit_code=exit_code,
707
+ truncated=truncated,
708
+ timeout_seconds=timeout_seconds,
717
709
  )
718
710
 
719
- def execute_denied(
711
+ def image_read(
720
712
  self,
721
- action: str,
722
- workdir: str | None,
723
- environment: str | None,
724
- refs: typing.Any,
725
- reason: str,
713
+ path: str,
714
+ volume: str,
715
+ *,
716
+ byte_count: int,
717
+ media_type: str,
726
718
  ):
727
- self._denied(
728
- AUDIT_SANDBOX_EXEC,
729
- action=action,
730
- workdir=workdir,
731
- environment=environment,
732
- refs=refs,
733
- reason=reason,
734
- )
735
-
736
- def volume_listed(self, volume: str, count: int):
737
719
  self._succeeded(
738
- AUDIT_SANDBOX_VOLUME_LIST,
720
+ AUDIT_SANDBOX_EXEC,
721
+ action=AUDIT_SANDBOX_ACTION_READ_IMAGE,
722
+ path=path,
739
723
  volume=volume,
740
- count=count,
724
+ byte_count=byte_count,
725
+ media_type=media_type,
741
726
  )
742
727
 
743
- def volume_list_failed(self, volume: str, reason: str):
728
+ def image_read_failed(self, path: str, reason: str):
744
729
  self._failed(
745
- AUDIT_SANDBOX_VOLUME_LIST,
746
- volume=volume,
730
+ AUDIT_SANDBOX_EXEC,
731
+ action=AUDIT_SANDBOX_ACTION_READ_IMAGE,
732
+ path=path,
747
733
  reason=reason,
748
734
  )
749
735
 
@@ -665,10 +665,9 @@ class WorkdirFile(pydantic.BaseModel):
665
665
  url: pydantic.HttpUrl
666
666
 
667
667
 
668
- class RunWorkdirFiles(pydantic.BaseModel):
668
+ class ThreadWorkdirFiles(pydantic.BaseModel):
669
669
  room_id: str
670
670
  thread_id: str
671
- run_id: str
672
671
  files: list[WorkdirFile]
673
672
 
674
673
 
@@ -0,0 +1,145 @@
1
+ import contextlib
2
+ import pathlib
3
+ import typing
4
+
5
+ from soliplex import loggers
6
+
7
+
8
+ class _SandboxExecRecorder:
9
+ """Carries what an execution reports back to the surrounding audit."""
10
+
11
+ def __init__(self):
12
+ self.workdir: pathlib.Path | None = None
13
+ self.refs: list[str] = []
14
+ self.exit_code: int | None = None
15
+ self.timed_out: bool = False
16
+ self.timeout_seconds: float | None = None
17
+ self.truncated: bool = False
18
+
19
+ @property
20
+ def logged_workdir(self) -> str | None:
21
+ return str(self.workdir) if self.workdir is not None else None
22
+
23
+ def record_workdir(self, workdir: pathlib.Path | None):
24
+ self.workdir = workdir
25
+
26
+ def record_ref(self, ref: str):
27
+ self.refs.append(ref)
28
+
29
+ def record_result(self, result):
30
+ self.exit_code = result.exit_code
31
+ self.timed_out = result.timed_out
32
+ self.timeout_seconds = result.timeout_seconds
33
+ self.truncated = result.truncated
34
+
35
+
36
+ class _SandboxImageReadRecorder:
37
+ """Carries what a read disclosed: which mount, how much, what type."""
38
+
39
+ def __init__(self):
40
+ self.volume: str = ""
41
+ self.byte_count: int = 0
42
+ self.media_type: str = ""
43
+
44
+ def record_image(self, volume: str, data: bytes, media_type: str):
45
+ self.volume = volume
46
+ self.byte_count = len(data)
47
+ self.media_type = media_type
48
+
49
+
50
+ def _audit_log(deps: typing.Any) -> loggers.SandboxExecAuditLog:
51
+ """Bind actor identity and run correlation from the agent deps."""
52
+ user = getattr(deps, "user", None)
53
+
54
+ return loggers.SandboxExecAuditLog(
55
+ claims=user.model_dump() if user is not None else {},
56
+ room_id=deps.room_id,
57
+ thread_id=deps.thread_id,
58
+ run_id=deps.run_id,
59
+ )
60
+
61
+
62
+ @contextlib.contextmanager
63
+ def audit_sandbox_exec(
64
+ deps: typing.Any,
65
+ *,
66
+ action: str,
67
+ environment: str | None,
68
+ ):
69
+ """Bracket a ``run`` / ``run_python`` tool body, emitting one
70
+ ``sandbox-exec`` record.
71
+
72
+ Yields a recorder the tool feeds with the working directory, each
73
+ transcript path, and the result. The command and script bodies are
74
+ never logged.
75
+ """
76
+ audit = _audit_log(deps)
77
+ recorder = _SandboxExecRecorder()
78
+
79
+ try:
80
+ yield recorder
81
+ except Exception as exc:
82
+ audit.execute_failed(
83
+ action,
84
+ recorder.logged_workdir,
85
+ environment,
86
+ recorder.refs,
87
+ type(exc).__name__,
88
+ exit_code=recorder.exit_code,
89
+ truncated=recorder.truncated,
90
+ timeout_seconds=recorder.timeout_seconds,
91
+ )
92
+ raise
93
+
94
+ bounds = {
95
+ "exit_code": recorder.exit_code,
96
+ "truncated": recorder.truncated,
97
+ "timeout_seconds": recorder.timeout_seconds,
98
+ }
99
+
100
+ if recorder.timed_out:
101
+ audit.execute_failed(
102
+ action,
103
+ recorder.logged_workdir,
104
+ environment,
105
+ recorder.refs,
106
+ loggers.AUDIT_SANDBOX_REASON_TIMEOUT,
107
+ **bounds,
108
+ )
109
+ elif recorder.exit_code: # neither a clean zero nor an absent status
110
+ audit.execute_failed(
111
+ action,
112
+ recorder.logged_workdir,
113
+ environment,
114
+ recorder.refs,
115
+ loggers.AUDIT_SANDBOX_REASON_EXIT_CODE,
116
+ **bounds,
117
+ )
118
+ else:
119
+ audit.executed(
120
+ action,
121
+ recorder.logged_workdir,
122
+ environment,
123
+ recorder.refs,
124
+ **bounds,
125
+ )
126
+
127
+
128
+ @contextlib.contextmanager
129
+ def audit_sandbox_read_image(deps: typing.Any, *, path: str):
130
+ """Bracket the ``read_image`` tool body, emitting one record."""
131
+ audit = _audit_log(deps)
132
+ recorder = _SandboxImageReadRecorder()
133
+
134
+ try:
135
+ yield recorder
136
+ except Exception as exc:
137
+ audit.image_read_failed(path, type(exc).__name__)
138
+ raise
139
+
140
+ audit.image_read(
141
+ path,
142
+ recorder.volume,
143
+ byte_count=recorder.byte_count,
144
+ media_type=recorder.media_type,
145
+ )