chore: minor configuration updates

This commit is contained in:
stevef 2026-03-27 17:17:41 +01:00
parent 50c8ee305a
commit 83b41bb875
24 changed files with 1066 additions and 185 deletions

View file

@ -95,6 +95,8 @@
🧠 **Smart routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
🛡️ **Hardened Multi-User Isolation**: Built-in [Tenant Isolation](docs/configuration.md#🔒-multi-tenant-agent-isolation) for shared infrastructure (Azure/ACA) — automatically partitions workspaces, memory, and tools (including MCP) per-user session.
_*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is planned. Boot speed comparison based on 0.8GHz single-core benchmarks (see table below)._
<div align="center">

View file

@ -28,5 +28,10 @@ func TestNewOnboardCommand(t *testing.T) {
encFlag := cmd.Flags().Lookup("enc")
require.NotNil(t, encFlag, "expected --enc flag to be registered")
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
assert.False(t, cmd.HasSubCommands())
yesFlag := cmd.Flags().Lookup("yes")
require.NotNil(t, yesFlag, "expected --yes flag to be registered")
assert.Equal(t, "false", yesFlag.DefValue, "--yes should default to false")
assert.True(t, cmd.HasSubCommands())
assert.Len(t, cmd.Commands(), 1)
assert.Equal(t, "purge", cmd.Commands()[0].Name())
}

View file

@ -68,6 +68,7 @@ func main() {
fmt.Printf("%s", banner)
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "\n❌ FATAL: %v\n", err)
os.Exit(1)
}
}

568
config/config.json.azure Normal file
View file

@ -0,0 +1,568 @@
{
"session": {
"dm_scope": "per-channel-peer"
},
"version": 1,
"agents": {
"defaults": {
"workspace": "",
"restrict_to_workspace": true,
"allow_read_outside_workspace": false,
"provider": "openai",
"model_name": "azure-grok",
"max_tokens": 32768,
"max_tool_iterations": 50,
"summarize_message_threshold": 20,
"summarize_token_percent": 75,
"steering_mode": "one-at-a-time",
"subturn": {
"max_depth": 10,
"max_concurrent": 5,
"default_timeout_minutes": 20,
"default_token_budget": 100000,
"concurrency_timeout_sec": 10
},
"tool_feedback": {
"enabled": true,
"max_args_length": 300
}
}
},
"channels": {
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": "",
"allow_from": [],
"reasoning_channel_id": ""
},
"telegram": {
"enabled": false,
"base_url": "",
"proxy": "",
"allow_from": [],
"group_trigger": {},
"typing": {
"enabled": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"streaming": {
"enabled": true,
"throttle_seconds": 3,
"min_growth_chars": 200
},
"reasoning_channel_id": "",
"use_markdown_v2": false
},
"feishu": {
"enabled": false,
"app_id": "",
"allow_from": [],
"group_trigger": {},
"placeholder": {},
"reasoning_channel_id": "",
"random_reaction_emoji": null,
"is_lark": false
},
"discord": {
"enabled": false,
"proxy": "",
"allow_from": [],
"mention_only": false,
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"allow_from": [],
"reasoning_channel_id": ""
},
"qq": {
"enabled": false,
"app_id": "",
"allow_from": [],
"group_trigger": {},
"max_message_length": 2000,
"max_base64_file_size_mib": 0,
"send_markdown": false,
"reasoning_channel_id": ""
},
"dingtalk": {
"enabled": false,
"client_id": "",
"allow_from": [],
"group_trigger": {},
"reasoning_channel_id": ""
},
"slack": {
"enabled": false,
"allow_from": [],
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"matrix": {
"enabled": false,
"homeserver": "https://matrix.org",
"user_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
},
"line": {
"enabled": false,
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"onebot": {
"enabled": false,
"ws_url": "ws://127.0.0.1:3001",
"reconnect_interval": 5,
"group_trigger_prefix": null,
"allow_from": [],
"group_trigger": {},
"typing": {},
"placeholder": {},
"reasoning_channel_id": ""
},
"wecom": {
"enabled": false,
"webhook_url": "",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5,
"group_trigger": {},
"reasoning_channel_id": ""
},
"wecom_app": {
"enabled": false,
"corp_id": "",
"agent_id": 0,
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5,
"group_trigger": {},
"reasoning_channel_id": ""
},
"wecom_aibot": {
"enabled": false,
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"reply_timeout": 5,
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly.",
"reasoning_channel_id": ""
},
"weixin": {
"enabled": false,
"base_url": "https://ilinkai.weixin.qq.com/",
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
"proxy": "",
"allow_from": [],
"reasoning_channel_id": ""
},
"pico": {
"enabled": false,
"ping_interval": 30,
"read_timeout": 60,
"write_timeout": 10,
"max_connections": 100,
"allow_from": [],
"placeholder": {}
},
"pico_client": {
"enabled": false,
"url": "",
"token": "",
"allow_from": null
},
"irc": {
"enabled": false,
"server": "",
"tls": false,
"nick": "",
"sasl_user": "",
"channels": null,
"allow_from": null,
"group_trigger": {},
"typing": {},
"reasoning_channel_id": ""
}
},
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_base": "https://api.anthropic.com/v1"
},
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"api_base": "https://api.deepseek.com/v1"
},
{
"model_name": "gemini-2.0-flash",
"model": "gemini/gemini-2.0-flash-exp",
"api_base": "https://generativelanguage.googleapis.com/v1beta"
},
{
"model_name": "qwen-plus",
"model": "qwen/qwen-plus",
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1"
},
{
"model_name": "moonshot-v1-8k",
"model": "moonshot/moonshot-v1-8k",
"api_base": "https://api.moonshot.cn/v1"
},
{
"model_name": "llama-3.3-70b",
"model": "groq/llama-3.3-70b-versatile",
"api_base": "https://api.groq.com/openai/v1"
},
{
"model_name": "openrouter-auto",
"model": "openrouter/auto",
"api_base": "https://openrouter.ai/api/v1"
},
{
"model_name": "openrouter-gpt-5.4",
"model": "openrouter/openai/gpt-5.4",
"api_base": "https://openrouter.ai/api/v1"
},
{
"model_name": "nemotron-4-340b",
"model": "nvidia/nemotron-4-340b-instruct",
"api_base": "https://integrate.api.nvidia.com/v1"
},
{
"model_name": "azure-grok",
"model": "openai/grok-4-fast-non-reasoning",
"api_base": "https://TestSJF.openai.azure.com/openai/v1/",
"api_key": "REDACTED"
},
{
"model_name": "cerebras-llama-3.3-70b",
"model": "cerebras/llama-3.3-70b",
"api_base": "https://api.cerebras.ai/v1"
},
{
"model_name": "vivgrid-auto",
"model": "vivgrid/auto",
"api_base": "https://api.vivgrid.com/v1"
},
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_base": "https://ark.cn-beijing.volces.com/api/v3"
},
{
"model_name": "doubao-pro",
"model": "volcengine/doubao-pro-32k",
"api_base": "https://ark.cn-beijing.volces.com/api/v3"
},
{
"model_name": "deepseek-v3",
"model": "shengsuanyun/deepseek-v3",
"api_base": "https://api.shengsuanyun.com/v1"
},
{
"model_name": "gemini-flash",
"model": "antigravity/gemini-3-flash",
"auth_method": "oauth"
},
{
"model_name": "copilot-gpt-5.4",
"model": "github-copilot/gpt-5.4",
"api_base": "http://localhost:4321",
"auth_method": "oauth"
},
{
"model_name": "llama3",
"model": "ollama/llama3",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "mistral-small",
"model": "mistral/mistral-small-latest",
"api_base": "https://api.mistral.ai/v1"
},
{
"model_name": "deepseek-v3.2",
"model": "avian/deepseek/deepseek-v3.2",
"api_base": "https://api.avian.io/v1"
},
{
"model_name": "kimi-k2.5",
"model": "avian/moonshotai/kimi-k2.5",
"api_base": "https://api.avian.io/v1"
},
{
"model_name": "MiniMax-M2.5",
"model": "minimax/MiniMax-M2.5",
"api_base": "https://api.minimaxi.com/v1",
"extra_body": {
"reasoning_split": true
}
},
{
"model_name": "LongCat-Flash-Thinking",
"model": "longcat/LongCat-Flash-Thinking",
"api_base": "https://api.longcat.chat/openai"
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_base": "https://api-inference.modelscope.cn/v1"
},
{
"model_name": "local-model",
"model": "vllm/custom-model",
"api_base": "http://localhost:8000/v1"
},
{
"model_name": "azure-gpt5",
"model": "azure/my-gpt5-deployment",
"api_base": "https://your-resource.openai.azure.com"
}
],
"gateway": {
"host": "0.0.0.0",
"port": 18790,
"chat_enabled": true,
"hot_reload": true,
"log_level": "info",
"api_key": "picoclaw-secret-123"
},
"hooks": {
"enabled": true,
"defaults": {
"observer_timeout_ms": 500,
"interceptor_timeout_ms": 5000,
"approval_timeout_ms": 60000
}
},
"tools": {
"filter_sensitive_data": true,
"filter_min_length": 8,
"allow_read_paths": null,
"allow_write_paths": null,
"deny_read_paths": [
"^skills(/.*)?$"
],
"deny_write_paths": [
"^skills(/.*)?$"
],
"web": {
"enabled": true,
"brave": {
"enabled": false,
"max_results": 5
},
"tavily": {
"enabled": false,
"base_url": "",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "",
"max_results": 5
},
"glm_search": {
"enabled": false,
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
"search_engine": "search_std",
"max_results": 5
},
"baidu_search": {
"enabled": false,
"base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search",
"max_results": 10
},
"prefer_native": true,
"fetch_limit_bytes": 10485760,
"format": "plaintext"
},
"cron": {
"enabled": true,
"exec_timeout_minutes": 5,
"allow_command": true
},
"exec": {
"enabled": true,
"enable_deny_patterns": true,
"allow_remote": true,
"custom_deny_patterns": null,
"custom_allow_patterns": null,
"timeout_seconds": 60
},
"skills": {
"whitelist_enabled": true,
"whitelist": [
"weather",
"summarize"
],
"enabled": true,
"registries": {
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
"search_path": "",
"skills_path": "",
"download_path": "",
"timeout": 0,
"max_zip_size": 0,
"max_response_size": 0
}
},
"github": {},
"max_concurrent_searches": 2,
"search_cache": {
"max_size": 50,
"ttl_seconds": 300
}
},
"media_cleanup": {
"enabled": true,
"max_age_minutes": 30,
"interval_minutes": 5
},
"mcp": {
"enabled": true,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {}
},
"whitelist": [
"spawn",
"subagent",
"read_file",
"list_dir",
"write_file",
"edit_file",
"append_file",
"message",
"weather",
"summarize",
"github",
"search_tool"
],
"whitelist_enabled": true,
"append_file": {
"enabled": true
},
"edit_file": {
"enabled": true
},
"find_skills": {
"enabled": true
},
"i2c": {
"enabled": false
},
"install_skill": {
"enabled": true
},
"list_dir": {
"enabled": true
},
"message": {
"enabled": true
},
"read_file": {
"enabled": true,
"max_read_file_size": 65536
},
"send_file": {
"enabled": true
},
"spawn": {
"enabled": true
},
"spawn_status": {
"enabled": false
},
"spi": {
"enabled": false
},
"subagent": {
"enabled": true
},
"web_fetch": {
"enabled": true
},
"write_file": {
"enabled": true
}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false,
"monitor_usb": true
},
"voice": {
"echo_transcription": false
},
"build_info": {
"version": "0.1.0",
"git_commit": "054b55fd",
"build_time": "2026-03-23T10:15:13+0100",
"go_version": "go1.26.1"
}
}

View file

@ -37,7 +37,18 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Create picoclaw home directory
# Create non-root user and group
# node image already has a 'node' user with UID 1000, so we remove it first
RUN deluser --remove-home node || true && \
addgroup -g 1000 picoclaw && \
adduser -D -u 1000 -G picoclaw picoclaw
# Switch to non-root user
USER picoclaw
WORKDIR /home/picoclaw
# Run onboard to create initial directories and config
# HOME will be /home/picoclaw
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]

View file

@ -77,7 +77,7 @@ When an incoming message includes a **ChatID** (passed in the `/chat` API or ext
1. **Isolated Workspace:** The agent's operations are restricted to `workspace/sessions/{isolationID}/workspace`.
2. **Isolated Memory:** Long-term memory (`MEMORY.md`) is stored and read from the isolated session path.
3. **Isolated Tools:** Tools like `read_file` and `write_file` are automatically pointed to the isolated workspace, preventing any tenant from accessing another's files or the global base workspace.
3. **Isolated Tools:** Tools like `read_file` and `write_file` are automatically pointed to the isolated workspace. Additionally, **MCP server tools** (e.g., Harvest, Monday) and discovery search tools are dynamically registered to each isolated instance, ensuring they inherit the same security boundaries.
#### Tenant Identification (Inbound Integration)
@ -99,6 +99,33 @@ If no `ChatID` is detected, the request is routed to the **Global Agent** contex
This mechanism is transparent to the end-user and the AI agent itself, ensuring a secure and portable multi-user environment out-of-the-box.
### 🚀 Onboarding & Automation
For automated deployments (like Azure Container Apps or CI/CD), the `onboard` command supports non-interactive execution and environment cleanup.
#### Automated Setup
Use the `--yes` (or `-y`) flag to skip all interactive prompts and automatically generate default credentials/keys:
```bash
picoclaw onboard --yes
```
#### Environment Purge
If you need to reset an environment (e.g., before a clean redeploy), use the `purge` subcommand. This removes existing workspaces, logs, and generated keys:
```bash
# Safe purge (checks if files exist)
picoclaw onboard purge
# Force purge (no confirmation)
picoclaw onboard purge --force
```
> [!WARNING]
> The `purge` command is destructive. It will delete your local session history, memory, and encrypted secrets. Only use it when you are prepared to start from a clean slate.
### Skill Sources
By default, skills are loaded from:

View file

@ -67,6 +67,21 @@ docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🔒 Hardened & Non-Root Deployment
For production environments (like Azure Container Apps or Kubernetes), use the **full hardened image** (`docker/Dockerfile.full`).
This image provides several security and reliability enhancements:
- **Non-Root Execution**: Runs as the `picoclaw` user (UID 1000) instead of root, meeting strict security requirements.
- **Volume Compatibility**: Fixed UID 1000 ensures compatibility with Azure Files and other cloud volume mounts without manual `chown` hacks.
- **Self-Contained**: Includes the full system suite (Node.js, Python, etc.) required for all tools.
- **Automated Onboarding**: The image entrypoint automatically triggers `picoclaw onboard --yes` if the environment is not initialized.
To build it manually:
```bash
docker build -f docker/Dockerfile.full -t picoclaw-full:latest .
```
### 🚀 Quick Start
> [!TIP]

View file

@ -37,6 +37,34 @@ See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full document
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering |
| `filter_min_length` | int | `8` | Minimum content length to trigger filtering |
## File Paths & Workspace Security
PicoClaw provides path-level security for all filesystem-related tools (`read_file`, `write_file`, `list_dir`, `edit_file`, `append_file`). This allows you to restrict the agent's access to specific patterns or block sensitive directories (like a `skills/` folder) even if they are inside the workspace.
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `allow_read_paths` | array | `[]` | Explicit regex patterns to allow reading from (even outside workspace) |
| `allow_write_paths` | array | `[]` | Explicit regex patterns to allow writing to (even outside workspace) |
| `deny_read_paths` | array | `[]` | Regex patterns to explicitly block from reading (overrides workspace access) |
| `deny_write_paths` | array | `[]` | Regex patterns to explicitly block from writing (overrides workspace access) |
### Path Deny Patterns
Deny patterns are useful for "hardening" a workspace. For example, to prevent an agent from manually tampering with its own skill configuration (the `skills/` directory), you can apply global block rules.
**Blocking the skills directory:**
```json
{
"tools": {
"deny_read_paths": ["^skills(/.*)?$"],
"deny_write_paths": ["^skills(/.*)?$"]
}
}
```
> **Note:** Deny patterns apply to the relative path within the workspace (when restricted) or the absolute path (when unrestricted). They take precedence over workspace access and whitelist patterns.
## Web Tools
Web tools are used for web search and fetching.

View file

@ -73,18 +73,20 @@ func NewAgentInstance(
// Compile path whitelist patterns from config.
allowReadPaths := buildAllowReadPatterns(cfg)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
denyReadPaths := compilePatterns(cfg.Tools.DenyReadPaths)
denyWritePaths := compilePatterns(cfg.Tools.DenyWritePaths)
toolsRegistry := tools.NewToolRegistry()
if cfg.Tools.IsToolEnabled("read_file") {
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths, denyReadPaths))
}
if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths, denyWritePaths))
}
if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths, denyReadPaths))
}
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
@ -97,10 +99,10 @@ func NewAgentInstance(
}
if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths, denyWritePaths))
}
if cfg.Tools.IsToolEnabled("append_file") {
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths, denyWritePaths))
}
// Use main agent workspace (no isolation) for sessions so that session history

View file

@ -404,6 +404,9 @@ func registerSharedTools(
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
}
// Register MCP and discovery tools to this agent
al.RegisterMCPToolsToAgent(agentID, agent)
// Apply global tools whitelist
agent.Tools.Filter(cfg.Tools.Whitelist, cfg.Tools.WhitelistEnabled)
}

View file

@ -8,7 +8,6 @@ package agent
import (
"context"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
@ -57,6 +56,12 @@ func (r *mcpRuntime) hasManager() bool {
return r.manager != nil
}
func (r *mcpRuntime) getManager() *mcp.Manager {
r.mu.Lock()
defer r.mu.Unlock()
return r.manager
}
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
// agent mode share the same initialization path.
func (al *AgentLoop) EnsureMCPInitialized(ctx context.Context) error {
@ -103,110 +108,100 @@ func (al *AgentLoop) EnsureMCPInitialized(ctx context.Context) error {
return
}
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
// Determine whether this server's tools should be deferred (hidden).
// Per-server "deferred" field takes precedence over the global Discovery.Enabled.
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if registerAsHidden {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
"deferred": registerAsHidden,
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
al.mcp.setInitErr(fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
))
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
al.mcp.setManager(mcpManager)
// Register MCP and discovery tools for all currently known agents
agentIDs := al.registry.ListAgentIDs()
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
al.RegisterMCPToolsToAgent(agentID, agent)
}
logger.InfoCF("agent", "MCP initialization complete",
map[string]any{
"server_count": len(mcpManager.GetServers()),
"agent_count": len(agentIDs),
})
})
return al.mcp.getInitErr()
}
// RegisterMCPToolsToAgent registers all currently active MCP tools and discovery tools to the given agent instance.
func (al *AgentLoop) RegisterMCPToolsToAgent(agentID string, agent *AgentInstance) {
if !al.cfg.Tools.MCP.Enabled {
return
}
mcpManager := al.mcp.getManager()
if mcpManager == nil {
return
}
// 1. Register MCP server tools
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
for _, tool := range conn.Tools {
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if registerAsHidden {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
}
}
if totalRegistrations > 0 {
logger.DebugCF("agent", "Registered MCP tools to agent",
map[string]any{
"agent_id": agentID,
"server_count": len(servers),
"tool_count": totalRegistrations,
})
}
// 2. Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
if useBM25 || useRegex {
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
logger.DebugCF("agent", "Initialized tool discovery for agent", map[string]any{
"agent_id": agentID, "bm25": useBM25, "regex": useRegex,
})
}
}
}
// serverIsDeferred reports whether an MCP server's tools should be registered
// as hidden (deferred/discovery mode).
//

View file

@ -0,0 +1,55 @@
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
mcp_pkg "github.com/sipeed/picoclaw/pkg/mcp"
)
func TestMultiUserMCPPropagation(t *testing.T) {
cfg := &config.Config{}
cfg.Agents.Defaults.Workspace = t.TempDir()
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
"test-server": {Enabled: true},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
// Mock initialized MCP manager
mcpManager := mcp_pkg.NewManager()
al.mcp.setManager(mcpManager)
// 1. Create a transient agent instance
agent := NewAgentInstance(&config.AgentConfig{ID: "test"}, &cfg.Agents.Defaults, cfg, provider, "user-123")
require.NotNil(t, agent)
// 2. Register tools initially (should be nothing)
al.RegisterMCPToolsToAgent("test", agent)
// Verify no MCP tools yet
_, ok := agent.Tools.Get("mcp_test_tool")
assert.False(t, ok)
// 3. Test Discovery tools registration
cfg.Tools.MCP.Discovery.Enabled = true
cfg.Tools.MCP.Discovery.UseRegex = true
t.Logf("Config before registration: MCP.Enabled=%v, Discovery.Enabled=%v, UseRegex=%v",
cfg.Tools.MCP.Enabled, cfg.Tools.MCP.Discovery.Enabled, cfg.Tools.MCP.Discovery.UseRegex)
// Call registration again - it should now add the discovery tool
al.RegisterMCPToolsToAgent("test", agent)
t.Logf("Registered tools: %v", agent.Tools.List())
_, ok = agent.Tools.Get("tool_search_tool_regex")
assert.True(t, ok, "Discovery tool (tool_search_tool_regex) should be registered after enabling it")
}

View file

@ -923,6 +923,8 @@ type ReadFileToolConfig struct {
type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
DenyReadPaths []string `json:"deny_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_DENY_READ_PATHS"`
DenyWritePaths []string `json:"deny_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_DENY_WRITE_PATHS"`
// FilterSensitiveData controls whether to filter sensitive values (API keys,
// tokens, secrets) from tool results before sending to the LLM.
// Default: true (enabled)

View file

@ -83,18 +83,30 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
// Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
fmt.Printf("🚀 PicoClaw Gateway starting...\n")
fmt.Printf("📂 Home Path: %s\n", homePath)
fmt.Printf("📄 Config Path: %s\n", configPath)
panicPath := filepath.Join(homePath, logPath, panicFile)
fmt.Printf("🔧 Initializing panic log: %s\n", panicPath)
panicFunc, err := logger.InitPanic(panicPath)
if err != nil {
return fmt.Errorf("error initializing panic log: %w", err)
fmt.Printf("⚠️ Warning: error initializing panic log (continuing): %v\n", err)
} else if panicFunc != nil {
defer panicFunc()
fmt.Println("✓ Panic log initialized")
}
defer panicFunc()
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
panic(fmt.Sprintf("error enabling file logging: %v", err))
logFilePath := filepath.Join(homePath, logPath, logFile)
fmt.Printf("🔧 Enabling file logging: %s\n", logFilePath)
if err = logger.EnableFileLogging(logFilePath); err != nil {
fmt.Printf("⚠️ Warning: error enabling file logging (continuing): %v\n", err)
} else {
defer logger.DisableFileLogging()
fmt.Println("✓ File logging enabled")
}
defer logger.DisableFileLogging()
fmt.Println("🔍 Loading configuration...")
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
@ -107,7 +119,6 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
fmt.Println("🔍 Debug mode enabled")
}
fmt.Printf("🔍 Creating startup provider for model: %s (allow empty: %v)\n", cfg.Agents.Defaults.GetModelName(), allowEmptyStartup)
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
if err != nil {
fmt.Printf("❌ Error creating provider: %v\n", err)

View file

@ -350,6 +350,8 @@ func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) {
// These are ordered by specificity/reliability
headers := []string{
"X-PicoClaw-Chat-ID",
"X-MS-CONVERSATION-ID", // Teams Conversation ID
"X-MS-TENANT-ID", // Teams Tenant ID
"X-User-ID",
"X-Session-ID",
"X-MS-CLIENT-PRINCIPAL-ID", // Azure App Service / Container Apps (EasyAuth)

View file

@ -14,7 +14,7 @@ func InitPanic(filePath string) (func(), error) {
}
writer := initPanicFile(filePath)
if writer == nil {
return nil, fmt.Errorf("failed to create log file: %s", filePath)
return nil, nil
}
return func() {
defer writer.Close()

View file

@ -13,10 +13,13 @@ import (
func initPanicFile(panicFile string) io.WriteCloser {
file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_SYNC, 0o600)
if err != nil {
panic(fmt.Sprintf("error in open panic: %v", err))
fmt.Fprintf(os.Stdout, "Failed to open panic log file %s: %v\n", panicFile, err)
return nil
}
if err = unix.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil {
panic(fmt.Sprintf("error in syscall.Dup2: %v", err))
fmt.Fprintf(os.Stdout, "Failed to dup2 panic log: %v\n", err)
file.Close()
return nil
}
return file
}

View file

@ -16,12 +16,12 @@ type EditFileTool struct {
}
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
func NewEditFileTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *EditFileTool {
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
}
return &EditFileTool{fs: buildFs(workspace, restrict, patterns)}
return &EditFileTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
}
func (t *EditFileTool) Name() string {
@ -79,12 +79,12 @@ type AppendFileTool struct {
fs fileSystem
}
func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
func NewAppendFileTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *AppendFileTool {
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
}
return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)}
return &AppendFileTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
}
func (t *AppendFileTool) Name() string {

View file

@ -16,7 +16,7 @@ func TestEditTool_EditFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
tool := NewEditFileTool(tmpDir, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -60,7 +60,7 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "nonexistent.txt")
tool := NewEditFileTool(tmpDir, true)
tool := NewEditFileTool(tmpDir, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -87,7 +87,7 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Hello World"), 0o644)
tool := NewEditFileTool(tmpDir, true)
tool := NewEditFileTool(tmpDir, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -114,7 +114,7 @@ func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test test test"), 0o644)
tool := NewEditFileTool(tmpDir, true)
tool := NewEditFileTool(tmpDir, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -142,7 +142,7 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
testFile := filepath.Join(otherDir, "test.txt")
os.WriteFile(testFile, []byte("content"), 0o644)
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
tool := NewEditFileTool(tmpDir, true, nil) // Restrict to tmpDir
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -169,7 +169,7 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
func TestEditTool_EditFile_MissingPath(t *testing.T) {
tool := NewEditFileTool("", false)
tool := NewEditFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"old_text": "old",
@ -186,7 +186,7 @@ func TestEditTool_EditFile_MissingPath(t *testing.T) {
// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text
func TestEditTool_EditFile_MissingOldText(t *testing.T) {
tool := NewEditFileTool("", false)
tool := NewEditFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@ -203,7 +203,7 @@ func TestEditTool_EditFile_MissingOldText(t *testing.T) {
// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text
func TestEditTool_EditFile_MissingNewText(t *testing.T) {
tool := NewEditFileTool("", false)
tool := NewEditFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@ -224,7 +224,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Initial content"), 0o644)
tool := NewAppendFileTool("", false)
tool := NewAppendFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -264,7 +264,7 @@ func TestEditTool_AppendFile_Success(t *testing.T) {
// TestEditTool_AppendFile_MissingPath verifies error handling for missing path
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
tool := NewAppendFileTool("", false)
tool := NewAppendFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"content": "test",
@ -280,7 +280,7 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) {
// TestEditTool_AppendFile_MissingContent verifies error handling for missing content
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
tool := NewAppendFileTool("", false)
tool := NewAppendFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@ -348,7 +348,7 @@ func TestReplaceEditContent(t *testing.T) {
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
workspace := t.TempDir()
tool := NewAppendFileTool(workspace, true)
tool := NewAppendFileTool(workspace, true, nil)
ctx := context.Background()
args := map[string]any{
@ -378,7 +378,7 @@ func TestAppendFileTool_Restricted_Success(t *testing.T) {
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
assert.NoError(t, err)
tool := NewAppendFileTool(workspace, true)
tool := NewAppendFileTool(workspace, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -402,7 +402,7 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
assert.NoError(t, err)
tool := NewEditFileTool(workspace, true)
tool := NewEditFileTool(workspace, true, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -423,7 +423,7 @@ func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
// error message when the target file does not exist.
func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
workspace := t.TempDir()
tool := NewEditFileTool(workspace, true)
tool := NewEditFileTool(workspace, true, nil)
ctx := context.Background()
args := map[string]any{
"path": "no_such_file.txt",

View file

@ -248,6 +248,19 @@ func isWithinWorkspace(candidate, workspace string) bool {
return err == nil && (rel == "." || filepath.IsLocal(rel))
}
func isDeniedPath(path string, patterns []*regexp.Regexp) bool {
if len(patterns) == 0 {
return false
}
cleaned := filepath.Clean(path)
for _, pattern := range patterns {
if pattern.MatchString(cleaned) {
return true
}
}
return false
}
type ReadFileTool struct {
fs fileSystem
maxSize int64
@ -257,11 +270,12 @@ func NewReadFileTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths ...[]*regexp.Regexp,
allowPaths []*regexp.Regexp,
denyPaths ...[]*regexp.Regexp,
) *ReadFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
}
maxSize := int64(maxReadFileSize)
@ -270,7 +284,7 @@ func NewReadFileTool(
}
return &ReadFileTool{
fs: buildFs(workspace, restrict, patterns),
fs: buildFs(workspace, restrict, allowPaths, denyPatterns),
maxSize: maxSize,
}
}
@ -483,12 +497,12 @@ type WriteFileTool struct {
fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
func NewWriteFileTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *WriteFileTool {
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
}
return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}
return &WriteFileTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
}
func (t *WriteFileTool) Name() string {
@ -551,12 +565,12 @@ type ListDirTool struct {
fs fileSystem
}
func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
func NewListDirTool(workspace string, restrict bool, allowPaths []*regexp.Regexp, denyPaths ...[]*regexp.Regexp) *ListDirTool {
var denyPatterns []*regexp.Regexp
if len(denyPaths) > 0 {
denyPatterns = denyPaths[0]
}
return &ListDirTool{fs: buildFs(workspace, restrict, patterns)}
return &ListDirTool{fs: buildFs(workspace, restrict, allowPaths, denyPatterns)}
}
func (t *ListDirTool) Name() string {
@ -615,9 +629,14 @@ type fileSystem interface {
}
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
type hostFs struct{}
type hostFs struct {
denyPatterns []*regexp.Regexp
}
func (h *hostFs) ReadFile(path string) ([]byte, error) {
if isDeniedPath(path, h.denyPatterns) {
return nil, fmt.Errorf("access denied: path is blocked by security policy")
}
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@ -632,16 +651,25 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) {
}
func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
if isDeniedPath(path, h.denyPatterns) {
return nil, fmt.Errorf("access denied: path is blocked by security policy")
}
return os.ReadDir(path)
}
func (h *hostFs) WriteFile(path string, data []byte) error {
if isDeniedPath(path, h.denyPatterns) {
return fmt.Errorf("access denied: path is blocked by security policy")
}
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return fileutil.WriteFileAtomic(path, data, 0o600)
}
func (h *hostFs) Open(path string) (fs.File, error) {
if isDeniedPath(path, h.denyPatterns) {
return nil, fmt.Errorf("access denied: path is blocked by security policy")
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@ -657,7 +685,8 @@ func (h *hostFs) Open(path string) (fs.File, error) {
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
type sandboxFs struct {
workspace string
workspace string
denyPatterns []*regexp.Regexp
}
func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
@ -676,6 +705,10 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
return err
}
if isDeniedPath(relPath, r.denyPatterns) {
return fmt.Errorf("access denied: path is blocked by security policy")
}
return fn(root, relPath)
}
@ -828,13 +861,13 @@ func (w *whitelistFs) Open(path string) (fs.File, error) {
// buildFs returns the appropriate fileSystem implementation based on restriction
// settings and optional path whitelist patterns.
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {
func buildFs(workspace string, restrict bool, allowPatterns, denyPatterns []*regexp.Regexp) fileSystem {
if !restrict {
return &hostFs{}
return &hostFs{denyPatterns: denyPatterns}
}
sandbox := &sandboxFs{workspace: workspace}
if len(patterns) > 0 {
return &whitelistFs{sandbox: sandbox, patterns: patterns}
sandbox := &sandboxFs{workspace: workspace, denyPatterns: denyPatterns}
if len(allowPatterns) > 0 {
return &whitelistFs{sandbox: sandbox, patterns: allowPatterns}
}
return sandbox
}

View file

@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
tool := NewReadFileTool("", false, MaxReadFileSize)
tool := NewReadFileTool("", false, MaxReadFileSize, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
tool := NewReadFileTool("", false, MaxReadFileSize)
tool := NewReadFileTool("", false, MaxReadFileSize, nil)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@ -88,7 +88,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -127,7 +127,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@ -153,7 +153,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"content": "test",
@ -169,7 +169,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@ -196,7 +196,7 @@ func TestFilesystemTool_WriteFile_OverwriteDefaultBlocked(t *testing.T) {
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
@ -219,7 +219,7 @@ func TestFilesystemTool_WriteFile_OverwriteExplicitAllowed(t *testing.T) {
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "replaced",
@ -239,7 +239,7 @@ func TestFilesystemTool_WriteFile_NewFileNoOverwriteFlag(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "brand new",
@ -259,7 +259,7 @@ func TestFilesystemTool_WriteFile_OverwriteFalseExplicitBlocked(t *testing.T) {
testFile := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(testFile, []byte("original"), 0o644)
tool := NewWriteFileTool("", false)
tool := NewWriteFileTool("", false, nil)
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"content": "new content",
@ -281,7 +281,7 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
testFile := "file.txt"
os.WriteFile(filepath.Join(workspace, testFile), []byte("original"), 0o644)
tool := NewWriteFileTool(workspace, true)
tool := NewWriteFileTool(workspace, true, nil)
// Without overwrite=true → blocked
result := tool.Execute(context.Background(), map[string]any{
@ -311,7 +311,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
tool := NewListDirTool("", false)
tool := NewListDirTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": tmpDir,
@ -335,7 +335,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
tool := NewListDirTool("", false)
tool := NewListDirTool("", false, nil)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_directory_12345",
@ -356,7 +356,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
tool := NewListDirTool("", false)
tool := NewListDirTool("", false, nil)
ctx := context.Background()
args := map[string]any{}
@ -386,7 +386,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
t.Skipf("symlink not supported in this environment: %v", err)
}
tool := NewReadFileTool(workspace, true, MaxReadFileSize)
tool := NewReadFileTool(workspace, true, MaxReadFileSize, nil)
result := tool.Execute(context.Background(), map[string]any{
"path": link,
})
@ -404,7 +404,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
}
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace=""
tool := NewReadFileTool("", true, MaxReadFileSize, nil) // restrict=true but workspace=""
// Try to read a sensitive file (simulated by a temp file outside workspace)
tmpDir := t.TempDir()
@ -457,7 +457,7 @@ func TestRootMkdirAll(t *testing.T) {
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
workspace := t.TempDir()
tool := NewWriteFileTool(workspace, true)
tool := NewWriteFileTool(workspace, true, nil)
ctx := context.Background()
testFile := "deep/nested/path/to/file.txt"
@ -733,7 +733,7 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize, nil)
ctx := context.Background()
// --- Step 1: Read the first chunk (10 bytes) ---
@ -822,7 +822,7 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize, nil)
ctx := context.Background()
args := map[string]any{
@ -843,3 +843,66 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
}
func TestFileSystem_DenyPatterns(t *testing.T) {
tmpDir := t.TempDir()
ctx := context.Background()
// Create a simulated skills directory
skillsDir := filepath.Join(tmpDir, "skills", "secret-skill")
os.MkdirAll(skillsDir, 0o755)
skillFile := filepath.Join(skillsDir, "SKILL.md")
os.WriteFile(skillFile, []byte("forbidden content"), 0o644)
// Create a normal file
normalFile := filepath.Join(tmpDir, "report.txt")
os.WriteFile(normalFile, []byte("allowed content"), 0o644)
// Test with deny patterns: block anything under skills/
denyPatterns := []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)}
t.Run("WriteFile blocked", func(t *testing.T) {
tool := NewWriteFileTool(tmpDir, true, nil, denyPatterns)
args := map[string]any{
"path": "skills/new-skill.md",
"content": "hacker stuff",
}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Fatal("Expected error when writing to denied path, but got success")
}
if !strings.Contains(result.ForLLM, "access denied") {
t.Errorf("Expected 'access denied' error, got: %s", result.ForLLM)
}
})
t.Run("ReadFile blocked", func(t *testing.T) {
tool := NewReadFileTool(tmpDir, true, 0, nil, denyPatterns)
args := map[string]any{"path": "skills/secret-skill/SKILL.md"}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Fatal("Expected error when reading from denied path, but got success")
}
})
t.Run("ListDir blocked", func(t *testing.T) {
tool := NewListDirTool(tmpDir, true, nil, denyPatterns)
args := map[string]any{"path": "skills"}
result := tool.Execute(ctx, args)
if !result.IsError {
t.Fatal("Expected error when listing denied path, but got success")
}
})
t.Run("Normal file allowed", func(t *testing.T) {
tool := NewReadFileTool(tmpDir, true, 0, nil, denyPatterns)
args := map[string]any{"path": "report.txt"}
result := tool.Execute(ctx, args)
if result.IsError {
t.Fatalf("Expected success for normal file, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "allowed content") {
t.Errorf("Got unexpected content: %s", result.ForLLM)
}
})
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@ -439,7 +440,22 @@ func (r *ToolRegistry) Filter(whitelist []string, enabled bool) {
removed := 0
for name := range r.tools {
if _, allowed := whitelistMap[name]; !allowed {
allowed := false
if _, exact := whitelistMap[name]; exact {
allowed = true
} else {
// Check for prefix matches (e.g. "monday" matches "mcp_monday_...")
for _, w := range whitelist {
// Match exact (redundant but safe) or prefix with underscore
// We also check for "mcp_" prefix specifically to support MCP tool grouping
if strings.HasPrefix(name, "mcp_"+w+"_") || strings.HasPrefix(name, "tool_"+w+"_") || strings.HasPrefix(name, w+"_") {
allowed = true
break
}
}
}
if !allowed {
delete(r.tools, name)
removed++
}

View file

@ -732,3 +732,42 @@ func TestToolRegistry_ExecuteWithContext_SanitizesInlineMediaWithoutStore(t *tes
t.Fatalf("expected inline media omission note, got %q", result.ForLLM)
}
}
func TestToolRegistry_Filter_SupportsPrefix(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "core tool"))
r.Register(newMockTool("write_file", "core tool"))
r.Register(newMockTool("mcp_monday_get_items", "mcp tool"))
r.Register(newMockTool("mcp_harvest_get_entries", "mcp tool"))
r.Register(newMockTool("tool_search_regex", "discovery tool"))
whitelist := []string{"read_file", "monday", "search"}
r.Filter(whitelist, true)
// expected: read_file (exact), mcp_monday_get_items (mcp_monday_ prefix), tool_search_regex (tool_search_ prefix)
if r.Count() != 3 {
t.Errorf("expected 3 tools after filtering, got %d: %v", r.Count(), r.List())
}
allowed := r.List()
expected := map[string]bool{
"read_file": true,
"mcp_monday_get_items": true,
"tool_search_regex": true,
}
for _, name := range allowed {
if !expected[name] {
t.Errorf("tool %q should have been filtered out", name)
}
delete(expected, name)
}
if len(expected) > 0 {
var missing []string
for m := range expected {
missing = append(missing, m)
}
t.Errorf("missing expected tools after filter: %v", missing)
}
}