# Vulnerability Verification Report

## 1. Summary
- **Vulnerability Type**: Path Traversal / Symlink-Hardlink Workspace Escape (CWE-22, CWE-62)
- **Flagged Location**: src/security/path.rs:68 and src/tools/filesystem.rs:82
- **Vulnerability Description**: ZeptoClaw before 0.7.6 is reported to allow a dangling symlink component bypass, TOCTOU between validation and use, and hardlink alias bypass around workspace file access.

## 2. Analysis Logic

### Step 1: Examining the file-operation sinks in `src/tools/filesystem.rs`
The filesystem tools take a JSON `path` argument, pass it through `resolve_path`, and then use the returned string in Tokio filesystem APIs:

```rust
// src/tools/filesystem.rs:76-87
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
    let path = args
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| PicoError::Tool("Missing 'path' argument".into()))?;

    let full_path = resolve_path(path, ctx)?;

    tokio::fs::read_to_string(&full_path)
        .await
        .map_err(|e| PicoError::Tool(format!("Failed to read file '{}': {}", full_path, e)))
}
```

```rust
// src/tools/filesystem.rs:140-164
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
    let path = args
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| PicoError::Tool("Missing 'path' argument".into()))?;

    let content = args
        .get("content")
        .and_then(|v| v.as_str())
        .ok_or_else(|| PicoError::Tool("Missing 'content' argument".into()))?;

    let full_path = resolve_path(path, ctx)?;

    // Create parent directories if they don't exist
    if let Some(parent) = Path::new(&full_path).parent() {
        if !parent.as_os_str().is_empty() {
            tokio::fs::create_dir_all(parent).await.map_err(|e| {
                PicoError::Tool(format!("Failed to create parent directories: {}", e))
            })?;
        }
    }

    tokio::fs::write(&full_path, content)
```

```rust
// src/tools/filesystem.rs:218-228
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
    let path = args
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| PicoError::Tool("Missing 'path' argument".into()))?;

    let full_path = resolve_path(path, ctx)?;

    let mut entries = tokio::fs::read_dir(&full_path).await.map_err(|e| {
        PicoError::Tool(format!("Failed to read directory '{}': {}", full_path, e))
    })?;
```

```rust
// src/tools/filesystem.rs:329-355
let full_path = resolve_path(path, ctx)?;

// Read the current content
let content = tokio::fs::read_to_string(&full_path)
    .await
    .map_err(|e| PicoError::Tool(format!("Failed to read file '{}': {}", full_path, e)))?;
...
// Write back
tokio::fs::write(&full_path, &new_content)
    .await
    .map_err(|e| PicoError::Tool(format!("Failed to write file '{}': {}", full_path, e)))?;
```

Analysis: the sinks are real file reads, directory reads, file creation/writes, and edit read-then-write operations. They all rely on a precomputed string path from `resolve_path`, then perform the filesystem access later.

### Step 2: Inspecting the workspace path validator in `src/security/path.rs`
`resolve_path` calls `validate_path_in_workspace` when a workspace is configured:

```rust
// src/tools/filesystem.rs:20-28
fn resolve_path(path: &str, ctx: &ToolContext) -> Result<String> {
    if let Some(ref workspace) = ctx.workspace {
        // Validate path stays within workspace
        let safe_path = validate_path_in_workspace(path, workspace)?;
        Ok(safe_path.as_path().to_string_lossy().to_string())
    } else {
        // No workspace set - allow any path (backwards compatible but less secure)
        Ok(path.to_string())
    }
}
```

The validator rejects obvious traversal strings, normalizes `.`/`..`, canonicalizes the workspace, and checks `starts_with`:

```rust
// src/security/path.rs:68-107
pub fn validate_path_in_workspace(path: &str, workspace: &str) -> Result<SafePath> {
    // Check for obvious traversal patterns in the raw input
    if contains_traversal_pattern(path) {
        return Err(PicoError::SecurityViolation(format!(
            "Path contains suspicious traversal pattern: {}",
            path
        )));
    }

    let workspace_path = Path::new(workspace);
    let target_path = Path::new(path);

    // Resolve the target path - join with workspace if relative
    let resolved_path = if target_path.is_absolute() {
        target_path.to_path_buf()
    } else {
        workspace_path.join(target_path)
    };

    // Normalize the path to resolve . and .. components
    let normalized_path = normalize_path(&resolved_path);

    // Get the canonical workspace path for comparison
    // If workspace doesn't exist, use the normalized workspace path
    let canonical_workspace = workspace_path
        .canonicalize()
        .unwrap_or_else(|_| normalize_path(workspace_path));

    // Check if the normalized path starts with the workspace
    if !normalized_path.starts_with(&canonical_workspace) {
        return Err(PicoError::SecurityViolation(format!(
            "Path escapes workspace: {} is not within {}",
            path, workspace
        )));
    }

    Ok(SafePath {
        path: normalized_path,
    })
}
```

The helper canonicalizes only if the whole normalized path exists; otherwise it returns the uncanonicalized path:

```rust
// src/security/path.rs:117-138
fn normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();

    for component in path.components() {
        match component {
            Component::ParentDir => {
                // Pop the last component if possible
                normalized.pop();
            }
            Component::CurDir => {
                // Skip current directory components
            }
            _ => {
                // Push all other components (Normal, RootDir, Prefix)
                normalized.push(component);
            }
        }
    }

    // Try to canonicalize if the path exists
    normalized.canonicalize().unwrap_or(normalized)
}
```

Analysis: this blocks direct `../` traversal, but it is not a complete workspace confinement check. A dangling final symlink or a symlinked parent with a not-yet-existing final component can take the `unwrap_or(normalized)` fallback at `src/security/path.rs:137`, leaving an apparently workspace-prefixed path that later `tokio::fs::write` follows at `src/tools/filesystem.rs:162`. The same validator also has no hardlink/inode policy: the complete validation logic at `src/security/path.rs:68-107` checks strings/canonical paths only, and then the sinks use the path by name.

### Step 3: Confirming a TOCTOU gap between validation and use
The code validates first and later opens/reads/writes by the path string. For reads, validation completes before the async file read:

```rust
// src/tools/filesystem.rs:82-86
let full_path = resolve_path(path, ctx)?;

tokio::fs::read_to_string(&full_path)
    .await
    .map_err(|e| PicoError::Tool(format!("Failed to read file '{}': {}", full_path, e)))
```

For writes, validation completes before parent directory creation and file write:

```rust
// src/tools/filesystem.rs:151-163
let full_path = resolve_path(path, ctx)?;

// Create parent directories if they don't exist
if let Some(parent) = Path::new(&full_path).parent() {
    if !parent.as_os_str().is_empty() {
        tokio::fs::create_dir_all(parent).await.map_err(|e| {
            PicoError::Tool(format!("Failed to create parent directories: {}", e))
        })?;
    }
}

tokio::fs::write(&full_path, content)
```

For edits, the tool validates once, reads, then writes the same path:

```rust
// src/tools/filesystem.rs:329-354
let full_path = resolve_path(path, ctx)?;

// Read the current content
let content = tokio::fs::read_to_string(&full_path)
    .await
    .map_err(|e| PicoError::Tool(format!("Failed to read file '{}': {}", full_path, e)))?;
...
// Write back
tokio::fs::write(&full_path, &new_content)
```

Analysis: there is no file descriptor opened during validation and no post-open verification. A path component can be replaced with a symlink after `resolve_path` returns and before `tokio::fs::read_to_string`, `tokio::fs::write`, or the edit write-back executes.

### Step 4: Tracing the path argument back through tool execution and the LLM response
The filesystem tool `path` argument is not hardcoded. Tools declare the `path` parameter in their JSON schemas:

```rust
// src/tools/filesystem.rs:63-73
fn parameters(&self) -> Value {
    json!({
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "The path to the file to read"
            }
        },
        "required": ["path"]
    })
}
```

The agent registers the filesystem tools as runtime tools:

```rust
// src/main.rs:165-171
// Register all tools
agent.register_tool(Box::new(EchoTool)).await;
agent.register_tool(Box::new(ReadFileTool)).await;
agent.register_tool(Box::new(WriteFileTool)).await;
agent.register_tool(Box::new(ListDirTool)).await;
agent.register_tool(Box::new(EditFileTool)).await;
agent.register_tool(Box::new(ShellTool)).await;
```

The agent sends available tool definitions to the provider and later executes returned tool calls:

```rust
// src/agent/loop.rs:214-228
// Get tool definitions
let tools = self.tools.read().await;
let tool_definitions = tools.definitions();
...
let mut response = provider
    .chat(messages, tool_definitions.clone(), model, options.clone())
    .await?;
```

```rust
// src/agent/loop.rs:261-269
for tool_call in &response.tool_calls {
    info!(tool = %tool_call.name, id = %tool_call.id, "Executing tool");

    let args: serde_json::Value =
        serde_json::from_str(&tool_call.arguments).unwrap_or_default();

    let result = match tools
        .execute_with_context(&tool_call.name, args, &tool_ctx)
        .await
```

The Claude provider converts the external API response `tool_use` input into the JSON argument string executed by the agent:

```rust
// src/providers/claude.rs:423-427
ClaudeContentBlock::ToolUse { id, name, input } => {
    // Convert input Value back to JSON string
    let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
    tool_calls.push(LLMToolCall::new(&id, &name, &arguments));
}
```

Analysis: the path can be supplied through a model tool call, not a fixed server-side value. The data flow is: provider `tool_use.input` -> `LLMToolCall.arguments` -> `serde_json::from_str` -> `ToolRegistry::execute_with_context` -> filesystem tool `args["path"]` -> `resolve_path` -> Tokio filesystem sink.

### Step 5: Tracing user-controlled entry points into the agent path
CLI input is copied directly into an inbound message processed by the agent:

```rust
// src/main.rs:216-220
if let Some(msg) = message {
    // Single message mode
    let inbound = InboundMessage::new("cli", "user", "cli", &msg);
    match agent.process_message(&inbound).await {
```

```rust
// src/main.rs:249-260
let input = input.trim();
if input.is_empty() {
    continue;
}
...
// Process message
let inbound = InboundMessage::new("cli", "user", "cli", input);
match agent.process_message(&inbound).await {
```

Telegram message text is also copied into an inbound message:

```rust
// src/channels/telegram.rs:205-223
// Only process text messages
if let Some(text) = msg.text() {
    let chat_id = msg.chat.id.0.to_string();
    ...
    // Create and publish the inbound message
    let inbound = InboundMessage::new("telegram", &user_id, &chat_id, text);

    if let Err(e) = bus.publish_inbound(inbound).await {
        error!("Failed to publish inbound message to bus: {}", e);
    }
}
```

The agent includes the inbound content when constructing LLM messages:

```rust
// src/agent/loop.rs:207-227
let mut session = self.session_manager.get_or_create(&msg.session_key).await?;

// Build messages with history
let messages = self
    .context_builder
    .build_messages(session.messages.clone(), &msg.content);

// Get tool definitions
let tools = self.tools.read().await;
let tool_definitions = tools.definitions();
...
let mut response = provider
    .chat(messages, tool_definitions.clone(), model, options.clone())
    .await?;
```

Analysis: an attacker-controlled prompt from CLI or Telegram can influence the LLM tool call arguments. Telegram may optionally use an allowlist, but the code explicitly treats an empty allowlist as allow-all at `src/channels/telegram.rs:196-202`, so the channel can be externally reachable when enabled without `allow_from`.

```rust
// src/channels/telegram.rs:196-202
// Check allowlist (empty = allow all)
if !allowlist.is_empty() && !allowlist.contains(&user_id) {
    info!(
        "Telegram: User {} not in allowlist, ignoring message",
        user_id
    );
    return Ok(());
}
```

### Step 6: Checking configured framework/library protections and code context
The security module exposes only the path validator module for this class of filesystem confinement:

```rust
// src/security/mod.rs:1-8
//! Security module for ZeptoClaw
//!
//! This module provides security utilities including path validation
//! and command filtering to prevent malicious tool execution.

pub mod path;

pub use path::{validate_path_in_workspace, SafePath};
```

The dependency manifest shows ordinary async/file-related dependencies and no repository-level sandbox dependency in the current package manifest:

```toml
# Cargo.toml:11-61
[dependencies]
# Async runtime
tokio = { version = "1.35", features = ["full"] }
...
# File paths
dirs = "5.0"
...
[dev-dependencies]
tokio-test = "0.4"
mockall = "0.12"
tempfile = "3.10"
```

The vulnerable implementations are production code before the test module begins:

```rust
// src/tools/filesystem.rs:51-54
pub struct ReadFileTool;

#[async_trait]
impl Tool for ReadFileTool {
```

```rust
// src/tools/filesystem.rs:365-366
#[cfg(test)]
mod tests {
```

Analysis: this is not isolated test/demo code. The production tool implementations appear before the `#[cfg(test)]` module and are registered by the main application at `src/main.rs:165-171`.

### Analysis Process
- Q1: Does user-controllable input influence the file path? → **YES.** CLI input is placed in `InboundMessage` at `src/main.rs:216-220` and `src/main.rs:249-260`; Telegram text is placed in `InboundMessage` at `src/channels/telegram.rs:205-223`; the agent sends that message with tool definitions to the LLM at `src/agent/loop.rs:207-227`; returned `tool_use.input` becomes executable JSON arguments at `src/providers/claude.rs:423-427` and `src/agent/loop.rs:261-269`; filesystem tools read `args["path"]` at `src/tools/filesystem.rs:76-82`, `src/tools/filesystem.rs:140-151`, and `src/tools/filesystem.rs:218-224`.
- Q2: Is the path canonicalized AND validated against a base directory in a way that prevents this issue? → **NO.** The validator performs `starts_with` after `normalize_path` at `src/security/path.rs:87-107`, but `normalize_path` falls back to the uncanonicalized path when canonicalization fails at `src/security/path.rs:136-137`. The sinks then follow the path later by name at `src/tools/filesystem.rs:82-86`, `src/tools/filesystem.rs:151-163`, and `src/tools/filesystem.rs:329-354`, enabling dangling-symlink, symlink-swap TOCTOU, and hardlink alias cases.
- Q3: Is basename/getName used to strip directory components? → **NO.** `resolve_path` passes the caller-supplied path to `validate_path_in_workspace` and returns `safe_path.as_path()` at `src/tools/filesystem.rs:20-24`; the validator joins relative paths with the workspace at `src/security/path.rs:80-85` and normalizes components at `src/security/path.rs:117-138`, not basename extraction.
- Q4: Is there an effective allowlist of permitted files? → **NO.** The tool schemas accept an arbitrary string `path` at `src/tools/filesystem.rs:63-73`, and the validator checks traversal patterns and workspace prefix at `src/security/path.rs:68-107`; no allowed filename set is consulted in those paths.
- Q5: Is the code in test/demo/dead/generated context? → **NO.** The filesystem tool implementations are production code starting at `src/tools/filesystem.rs:51-54`, the test module begins later at `src/tools/filesystem.rs:365-366`, and the main application registers these tools at `src/main.rs:165-171`.
- → Reached leaf: **TRUE_POSITIVE**

## 3. Verdict
**TRUE_POSITIVE**

**Key Evidence:**
- The sink validates a path once and then performs file reads/writes by the same path string later (`src/tools/filesystem.rs:82-86`, `src/tools/filesystem.rs:151-163`, `src/tools/filesystem.rs:329-354`).
- The canonicalization helper falls back to an uncanonicalized path when the full target cannot be canonicalized (`src/security/path.rs:136-137`), which leaves dangling symlink and non-existent-final-component symlink cases inside the workspace prefix check.
- The path is attacker-influenced through CLI/Telegram messages and LLM tool calls (`src/main.rs:216-220`, `src/channels/telegram.rs:205-223`, `src/providers/claude.rs:423-427`, `src/agent/loop.rs:261-269`).

## 4. Remediation Suggestions
- Open files using a safe workspace-relative mechanism that prevents symlink following, such as `openat`-style traversal with `O_NOFOLLOW` on every component where supported.
- For writes/edits, validate parent directories with canonicalization, reject symlink components, then create/open using a file descriptor rooted in the trusted workspace; re-check metadata after open.
- Add hardlink defenses where workspace isolation is a security boundary, such as rejecting files with unexpected link counts or enforcing ownership/device/inode policy before read/write.
- Keep the existing `../` checks as defense-in-depth, but do not rely on string normalization plus `starts_with` as the primary sandbox boundary.