-
Notifications
You must be signed in to change notification settings - Fork 2
Add per-tool sandbox configuration and tool config dialog #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,17 +112,35 @@ def _setup_sandbox(self) -> None: | |
| """Set up the Python sandbox with restrictions.""" | ||
| workspace = str(self.config.workspace_path) | ||
|
|
||
| # Get timeout from tool_configs or fall back to config defaults | ||
| # Get per-tool config or fall back to config defaults | ||
| timeout = self.config.sandbox.timeout_seconds | ||
| py_cfg: dict[str, Any] = {} | ||
| if self.tool_configs and "python_execute" in self.tool_configs: | ||
| py_cfg = self.tool_configs["python_execute"] | ||
| timeout = py_cfg.get("timeoutSeconds", timeout) | ||
|
|
||
| # Build path lists: workspace is always included, configs can only add | ||
| blocked_paths = set(py_cfg.get("blockedPaths", [])) | ||
| read_paths = [workspace] + [ | ||
| p for p in py_cfg.get("allowedReadPaths", []) if p not in blocked_paths | ||
| ] | ||
| write_paths = [workspace] + [ | ||
| p for p in py_cfg.get("allowedWritePaths", []) if p not in blocked_paths | ||
| ] | ||
|
|
||
| # Build import lists: base allowed + extra, with separate blocked set | ||
| allowed_imports = list(self.config.sandbox.allowed_imports) | ||
| for mod in py_cfg.get("extraImports", []): | ||
| if mod not in allowed_imports: | ||
| allowed_imports.append(mod) | ||
| blocked_imports = py_cfg.get("blockedImports", []) or None | ||
|
|
||
| self.sandbox = PythonSandbox( | ||
| allowed_imports=self.config.sandbox.allowed_imports, | ||
| allowed_imports=allowed_imports, | ||
| blocked_imports=blocked_imports, | ||
| timeout_seconds=timeout, | ||
| allowed_read_paths=[workspace], | ||
| allowed_write_paths=[workspace], | ||
| allowed_read_paths=read_paths, | ||
| allowed_write_paths=write_paths, | ||
| ) | ||
|
Comment on lines
+115
to
144
|
||
|
|
||
| def _build_registry_from_plugins(self) -> None: | ||
|
|
@@ -313,12 +331,12 @@ def _on_tool_end_with_capture(tool_name: str, result: Any) -> None: | |
|
|
||
| self._agent = PromptureAgent(**agent_kwargs) | ||
|
|
||
| @staticmethod | ||
| def _harden_security_context(ctx: object) -> None: | ||
| def _harden_security_context(self, ctx: object) -> None: | ||
| """Add security restrictions to prevent secret leakage from bot agents. | ||
|
|
||
| Blocks .env file access and dangerous shell commands that could | ||
| expose environment variables or secrets. | ||
| Blocks .env file access, dangerous shell commands that could | ||
| expose environment variables or secrets, and user-configured | ||
| blocked paths. | ||
| """ | ||
| # Block .env files via ignore patterns (matched against filename) | ||
| ignore = getattr(ctx, "ignore_patterns", None) | ||
|
|
@@ -334,6 +352,18 @@ def _harden_security_context(ctx: object) -> None: | |
| if cmd not in blocked: | ||
| blocked.append(cmd) | ||
|
|
||
| # Apply user-configured blocked paths to the security context | ||
| py_cfg: dict[str, Any] = {} | ||
| if self.tool_configs and "python_execute" in self.tool_configs: | ||
| py_cfg = self.tool_configs["python_execute"] | ||
| blocked_paths_cfg = py_cfg.get("blockedPaths", []) | ||
| if blocked_paths_cfg: | ||
| ctx_blocked = getattr(ctx, "blocked_paths", None) | ||
| if ctx_blocked is not None: | ||
| for p in blocked_paths_cfg: | ||
| if p not in ctx_blocked: | ||
| ctx_blocked.append(p) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Auto-capture: file_write → Asset | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,10 +5,12 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from tukuy.manifest import PluginManifest, PluginRequirements | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from tukuy.skill import RiskLevel, Skill, skill | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from tukuy.skill import ConfigParam, RiskLevel, Skill, skill | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from cachibot.plugins.base import CachibotPlugin, PluginContext | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _ENCODING_OPTIONS = ["utf-8", "ascii", "latin-1", "utf-16"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class FileOpsPlugin(CachibotPlugin): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Provides workspace-restricted file operation tools.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -48,6 +50,26 @@ def _build_skills(self) -> dict[str, Skill]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Read File", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| icon="file-text", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| risk_level=RiskLevel.SAFE, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config_params=[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConfigParam( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name="maxFileSize", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Max File Size", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Maximum file size allowed for reading.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="number", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default=10485760, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| min=1024, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| max=104857600, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| unit="bytes", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConfigParam( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name="encoding", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Encoding", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Text encoding used when reading files.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="select", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default="utf-8", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| options=_ENCODING_OPTIONS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def file_read(path: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Read the contents of a file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,7 +84,18 @@ def file_read(path: str) -> str: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not ctx.config.is_path_allowed(full_path): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return f"Error: Path '{path}' is outside the workspace" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ctx.sandbox.read_file(str(full_path)) # type: ignore[no-any-return] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cfg = ctx.tool_configs.get("file_read", {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| max_size: int = cfg.get("maxFileSize", 10485760) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| encoding: str = cfg.get("encoding", "utf-8") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| file_size = full_path.stat().st_size | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if file_size > max_size: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"Error: File '{path}' is {file_size} bytes, " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"exceeds limit of {max_size} bytes" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ctx.sandbox.read_file(str(full_path), encoding=encoding) # type: ignore[no-any-return] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return f"Error reading file: {e}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -76,6 +109,24 @@ def file_read(path: str) -> str: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Write File", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| icon="file-pen", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| risk_level=RiskLevel.MODERATE, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| config_params=[ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConfigParam( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name="encoding", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Encoding", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Text encoding used when writing files.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="select", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default="utf-8", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| options=_ENCODING_OPTIONS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ConfigParam( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name="allowedExtensions", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display_name="Allowed Extensions", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="File extensions allowed for writing. Empty list allows all.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="string[]", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default=[], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| item_placeholder=".txt, .json, .csv", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def file_write(path: str, content: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Write content to a file. Creates the file if it doesn't exist. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -91,8 +142,18 @@ def file_write(path: str, content: str) -> str: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not ctx.config.is_path_allowed(full_path): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return f"Error: Path '{path}' is outside the workspace" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cfg = ctx.tool_configs.get("file_write", {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| encoding: str = cfg.get("encoding", "utf-8") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| allowed_ext: list[str] = cfg.get("allowedExtensions", []) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if allowed_ext and full_path.suffix not in allowed_ext: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"Error: Extension '{full_path.suffix}' is not allowed. " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"Allowed: {', '.join(allowed_ext)}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+147
to
+152
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| allowed_ext: list[str] = cfg.get("allowedExtensions", []) | |
| if allowed_ext and full_path.suffix not in allowed_ext: | |
| return ( | |
| f"Error: Extension '{full_path.suffix}' is not allowed. " | |
| f"Allowed: {', '.join(allowed_ext)}" | |
| # Normalize allowedExtensions config to a list of lowercase, dot-prefixed extensions. | |
| raw_allowed_ext = cfg.get("allowedExtensions", []) | |
| normalized_allowed_ext: list[str] = [] | |
| if isinstance(raw_allowed_ext, str): | |
| raw_iter = [raw_allowed_ext] | |
| elif isinstance(raw_allowed_ext, (list, tuple, set)): | |
| raw_iter = raw_allowed_ext | |
| else: | |
| raw_iter = [] | |
| for ext in raw_iter: | |
| if not isinstance(ext, str): | |
| continue | |
| cleaned = ext.strip().lower() | |
| if not cleaned: | |
| continue | |
| if not cleaned.startswith("."): | |
| cleaned = "." + cleaned | |
| normalized_allowed_ext.append(cleaned) | |
| suffix = full_path.suffix.lower() | |
| if normalized_allowed_ext and suffix not in normalized_allowed_ext: | |
| return ( | |
| f"Error: Extension '{full_path.suffix}' is not allowed. " | |
| f"Allowed: {', '.join(normalized_allowed_ext)}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,53 @@ def _build_skills(self) -> dict[str, Skill]: | |
| step=1000, | ||
| unit="chars", | ||
| ), | ||
| ConfigParam( | ||
| name="allowedReadPaths", | ||
| display_name="Extra Read Paths", | ||
| description="Additional directories the sandbox can read from " | ||
| "(workspace is always included).", | ||
| type="string[]", | ||
| default=[], | ||
| placeholder="/data/shared", | ||
| max_items=20, | ||
| ), | ||
| ConfigParam( | ||
| name="allowedWritePaths", | ||
| display_name="Extra Write Paths", | ||
| description="Additional directories the sandbox can write to " | ||
| "(workspace is always included).", | ||
| type="string[]", | ||
| default=[], | ||
| placeholder="/data/output", | ||
| max_items=20, | ||
| ), | ||
| ConfigParam( | ||
| name="blockedPaths", | ||
| display_name="Blocked Paths", | ||
| description="Directories forbidden from access, even if within allowed paths.", | ||
| type="string[]", | ||
| default=[], | ||
| placeholder="/etc/secrets", | ||
| max_items=20, | ||
| ), | ||
|
Comment on lines
+68
to
+96
|
||
| ConfigParam( | ||
| name="extraImports", | ||
| display_name="Extra Imports", | ||
| description="Additional Python modules to allow beyond the default safe set.", | ||
| type="string[]", | ||
| default=[], | ||
| placeholder="requests", | ||
| max_items=50, | ||
| ), | ||
| ConfigParam( | ||
| name="blockedImports", | ||
| display_name="Blocked Imports", | ||
| description="Python modules to block, even if in the default safe set.", | ||
| type="string[]", | ||
| default=[], | ||
| placeholder="csv", | ||
| max_items=50, | ||
| ), | ||
| ], | ||
| ) | ||
| def python_execute(code: str) -> str: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self.tool_configsultimately comes from unvalidated JSON (e.g., websocket payload). Herepy_cfg.get("blockedPaths"),allowedReadPaths, etc. are assumed to be list-like; if a client sendsnullor a non-list (string/object),set(...)/ list comprehensions will raise and prevent the agent from starting. Consider normalizing each config field (treatNoneas[], enforcelist[str], drop non-strings) before buildingblocked_paths,read_paths, andwrite_paths(and similarly for import lists).