diff --git a/cachibot/agent.py b/cachibot/agent.py index a9360b4..1ab8d61 100644 --- a/cachibot/agent.py +++ b/cachibot/agent.py @@ -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, ) 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 # ------------------------------------------------------------------ diff --git a/cachibot/plugins/file_ops.py b/cachibot/plugins/file_ops.py index 41d13c5..429cdc3 100644 --- a/cachibot/plugins/file_ops.py +++ b/cachibot/plugins/file_ops.py @@ -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)}" + ) + full_path.parent.mkdir(parents=True, exist_ok=True) - ctx.sandbox.write_file(str(full_path), content) + ctx.sandbox.write_file(str(full_path), content, encoding=encoding) return f"Successfully wrote to {path}" except Exception as e: return f"Error writing file: {e}" @@ -154,6 +215,26 @@ def file_list(path: str = ".", pattern: str = "*", recursive: bool = False) -> s display_name="Edit File", icon="file-search", risk_level=RiskLevel.MODERATE, + config_params=[ + ConfigParam( + name="maxFileSize", + display_name="Max File Size", + description="Maximum file size allowed for editing.", + type="number", + default=10485760, + min=1024, + max=104857600, + unit="bytes", + ), + ConfigParam( + name="allowedExtensions", + display_name="Allowed Extensions", + description="File extensions allowed for editing. Empty list allows all.", + type="string[]", + default=[], + item_placeholder=".txt, .json, .csv", + ), + ], ) def file_edit(path: str, old_text: str, new_text: str) -> str: """Edit a file by replacing a specific string with new content. @@ -170,6 +251,23 @@ def file_edit(path: str, old_text: str, new_text: 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_edit", {}) + max_size: int = cfg.get("maxFileSize", 10485760) + 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)}" + ) + + 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" + ) + content = ctx.sandbox.read_file(str(full_path)) if old_text not in content: return f"Error: Could not find the specified text in {path}" diff --git a/cachibot/plugins/python_sandbox.py b/cachibot/plugins/python_sandbox.py index db35e8f..1f0ebff 100644 --- a/cachibot/plugins/python_sandbox.py +++ b/cachibot/plugins/python_sandbox.py @@ -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, + ), + 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: diff --git a/cachibot/services/bot_creation_service.py b/cachibot/services/bot_creation_service.py index 3a5fc8f..d7ef86b 100644 --- a/cachibot/services/bot_creation_service.py +++ b/cachibot/services/bot_creation_service.py @@ -23,7 +23,7 @@ async def _extract_with_retry( last_error: Exception | None = None for attempt in range(max_retries): try: - return await extract_with_model( # type: ignore[no-any-return] + return await extract_with_model( model_cls, text, model_name, diff --git a/frontend/src/components/views/ToolsView.tsx b/frontend/src/components/views/ToolsView.tsx index 8e39226..5edcfcd 100644 --- a/frontend/src/components/views/ToolsView.tsx +++ b/frontend/src/components/views/ToolsView.tsx @@ -15,9 +15,9 @@ import { ToggleRight, Info, Settings, - Play, Loader2, AlertCircle, + X, } from 'lucide-react' import { useBotStore } from '../../stores/bots' import { usePlatformToolsStore } from '../../stores/platform-tools' @@ -367,13 +367,17 @@ export function ToolsView() { - {/* Tool details panel */} + {/* Tool detail modal */} {selectedTool && ( - handleToggleTool(selectedTool.id)} onClose={() => setSelectedTool(null)} + onConfigure={() => { + setConfigTool(selectedTool) + setSelectedTool(null) + }} /> )} @@ -475,17 +479,18 @@ function ToolCard({ tool, enabled, onToggle, onSelect, onConfigure }: ToolCardPr } // ============================================================================= -// TOOL DETAILS PANEL +// TOOL DETAIL DIALOG (modal) // ============================================================================= -interface ToolDetailsPanelProps { +interface ToolDetailDialogProps { tool: Tool enabled: boolean onToggle: () => void onClose: () => void + onConfigure: () => void } -function ToolDetailsPanel({ tool, enabled, onToggle, onClose }: ToolDetailsPanelProps) { +function ToolDetailDialog({ tool, enabled, onToggle, onClose, onConfigure }: ToolDetailDialogProps) { const riskConfig = { safe: { icon: Shield, @@ -515,87 +520,95 @@ function ToolDetailsPanel({ tool, enabled, onToggle, onClose }: ToolDetailsPanel const config = riskConfig[tool.riskLevel] || riskConfig.safe const RiskIcon = config.icon + const hasConfigParams = tool.configParams && tool.configParams.length > 0 return ( -
- {/* Header */} -
-

Tool Details

- -
- -
- {/* Tool info */} -
-
- -
-
-

{tool.name}

-

- {categoryConfig[tool.category]?.label || tool.category} -

+
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* Header */} +
+
+
+ +
+
+
+

{tool.name}

+ + + {tool.riskLevel} + +
+

+ {categoryConfig[tool.category]?.label || tool.category} +

+
+
- {/* Risk level */} -
-
- - {tool.riskLevel} Risk + {/* Body */} +
+ {/* Description */} +
+

Description

+

{tool.description}

-

- {tool.riskLevel === 'safe' && - 'This tool is safe to use and cannot modify system resources.'} - {tool.riskLevel === 'moderate' && - 'This tool can modify files or execute code. Actions may require approval.'} - {tool.riskLevel === 'dangerous' && - 'This tool can perform system-level operations. Use with caution.'} - {tool.riskLevel === 'critical' && - 'This tool performs critical operations with significant risk. Requires explicit approval.'} -

-
- - {/* Description */} -
-

Description

-

{tool.description}

-
- {/* Parameters */} - {tool.parameters && tool.parameters.length > 0 && ( -
-

Parameters

-
- {tool.parameters.map((param) => ( -
-
- {param.name} - - {param.type} - - {param.required && ( - - required + {/* Parameters */} + {tool.parameters && tool.parameters.length > 0 && ( +
+

Parameters

+
+ {tool.parameters.map((param) => ( +
+
+ {param.name} + + {param.type} - )} + {param.required && ( + + required + + )} +
+

{param.description}

-

{param.description}

-
- ))} + ))} +
-
- )} + )} - {/* Actions */} -
+ {/* Configure button */} + {hasConfigParams && ( + + )} +
+ + {/* Footer */} +
-
diff --git a/frontend/src/styles/components/tools-view.less b/frontend/src/styles/components/tools-view.less index 43c9cfe..6e5ecf7 100644 --- a/frontend/src/styles/components/tools-view.less +++ b/frontend/src/styles/components/tools-view.less @@ -404,169 +404,3 @@ } } -// ----------------------------------------------------------------------------- -// Tool Details Panel -// ----------------------------------------------------------------------------- - -.tool-details-panel { - width: 24rem; - flex-shrink: 0; - border-left: 1px solid var(--color-border-primary); - background: var(--color-bg-tertiary); - - .dark & { - background: color-mix(in srgb, var(--color-bg-primary) 50%, transparent); - } - - &__header { - .flex-between(); - padding: @sp-3 @sp-4; - border-bottom: 1px solid var(--color-border-primary); - } - - &__title { - font-weight: @font-semibold; - color: var(--color-text-primary); - } - - &__close { - border-radius: @radius-sm; - padding: @sp-1; - color: var(--color-text-secondary); - background: none; - border: none; - cursor: pointer; - .transition-colors(); - - &:hover { - background: var(--color-hover-bg); - color: var(--color-text-primary); - } - } - - &__body { - padding: @sp-4; - } - - &__icon-box { - .flex-center(); - height: 3.5rem; - width: 3.5rem; - border-radius: @radius-xl; - background: var(--color-bg-secondary); - } - - &__icon { - height: 1.75rem; - width: 1.75rem; - color: var(--color-text-primary); - } - - &__name { - font-size: @text-lg; - font-weight: @font-bold; - color: var(--color-text-primary); - } - - &__category { - font-size: @text-sm; - color: var(--color-text-secondary); - } - - &__section-title { - margin-bottom: @sp-2; - font-size: @text-sm; - font-weight: @font-medium; - color: var(--color-text-primary); - } - - &__risk-description { - margin-top: @sp-2; - font-size: @text-sm; - color: var(--color-text-secondary); - } -} - -// ----------------------------------------------------------------------------- -// Tool Parameter Card -// ----------------------------------------------------------------------------- - -.tool-param-card { - border-radius: @radius-lg; - border: 1px solid var(--color-border-primary); - background: var(--color-bg-primary); - padding: @sp-3; - - &__type { - border-radius: @radius-sm; - background: var(--color-bg-secondary); - padding: @sp-0-5 @sp-1-5; - font-size: @text-xs; - color: var(--color-text-secondary); - } - - &__required { - border-radius: @radius-sm; - background: rgba(@red-500, 0.2); - padding: @sp-0-5 @sp-1-5; - font-size: @text-xs; - color: @red-400; - } - - &__description { - margin-top: @sp-1; - font-size: @text-xs; - color: var(--color-text-secondary); - } -} - -// ----------------------------------------------------------------------------- -// Tool Details Actions -// ----------------------------------------------------------------------------- - -.tool-details-actions { - display: flex; - gap: @sp-2; -} - -.tool-details-btn { - .flex-center(); - gap: @sp-2; - border-radius: @radius-lg; - padding: @sp-2; - font-size: @text-sm; - font-weight: @font-medium; - border: none; - cursor: pointer; - .transition-colors(); - - &--enable { - flex: 1; - background: var(--accent-600); - color: white; - - &:hover { - background: var(--accent-500); - } - } - - &--disable { - flex: 1; - background: rgba(@red-600, 0.2); - color: @red-400; - - &:hover { - background: rgba(@red-600, 0.3); - } - } - - &--test { - background: var(--color-bg-secondary); - color: var(--color-text-primary); - padding: @sp-2 @sp-4; - - &:hover { - background: var(--color-hover-bg); - } - } -} diff --git a/pyproject.toml b/pyproject.toml index ead2599..cb0e737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ - "prompture>=1.0.43", + "prompture>=1.0.48", "tukuy>=0.0.30", "typer>=0.12.0", "rich>=13.0.0", diff --git a/tests/test_security.py b/tests/test_security.py index 353c0aa..c009485 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -378,11 +378,13 @@ def test_harden_adds_env_to_ignore_patterns(self): """The hardening adds .env patterns to ignore_patterns.""" from cachibot.agent import CachibotAgent + agent_mock = MagicMock(spec=CachibotAgent) + agent_mock.tool_configs = None ctx = MagicMock() ctx.ignore_patterns = [] ctx.blocked_commands = [] - CachibotAgent._harden_security_context(ctx) + CachibotAgent._harden_security_context(agent_mock, ctx) assert ".env" in ctx.ignore_patterns assert "*.env" in ctx.ignore_patterns @@ -392,11 +394,13 @@ def test_harden_adds_blocked_commands(self): """The hardening blocks env-dumping shell commands.""" from cachibot.agent import CachibotAgent + agent_mock = MagicMock(spec=CachibotAgent) + agent_mock.tool_configs = None ctx = MagicMock() ctx.ignore_patterns = [] ctx.blocked_commands = [] - CachibotAgent._harden_security_context(ctx) + CachibotAgent._harden_security_context(agent_mock, ctx) for cmd in ("env", "printenv", "set", "export"): assert cmd in ctx.blocked_commands @@ -405,12 +409,14 @@ def test_harden_idempotent(self): """Calling _harden twice doesn't duplicate entries.""" from cachibot.agent import CachibotAgent + agent_mock = MagicMock(spec=CachibotAgent) + agent_mock.tool_configs = None ctx = MagicMock() ctx.ignore_patterns = [] ctx.blocked_commands = [] - CachibotAgent._harden_security_context(ctx) - CachibotAgent._harden_security_context(ctx) + CachibotAgent._harden_security_context(agent_mock, ctx) + CachibotAgent._harden_security_context(agent_mock, ctx) assert ctx.ignore_patterns.count(".env") == 1 assert ctx.blocked_commands.count("env") == 1