# Vulnerability Verification Report

## 1. Summary
- **Vulnerability Type**: Path Traversal / Symlink Escape / TOCTOU (CWE-22, CWE-62)
- **Flagged Location**: `src/security/path.rs:68` (validate_path_in_workspace), `src/tools/filesystem.rs:20` (resolve_path)
- **Vulnerability Description**: ZeptoClaw is a personal AI assistant. Prior to 0.7.6, there is a Dangling Symlink Component Bypass, TOCTOU Between Validation and Use, and Hardlink Alias Bypass in the filesystem tool path validation logic.

## 2. Analysis Logic

### Step 1: Examining the flagged sink at `src/security/path.rs:68`
The function `validate_path_in_workspace` is the core path validation routine. It takes a user-provided path string and a workspace directory string, and attempts to verify the resolved path stays within the workspace.

```rust
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,
    })
}
```

**Analysis**: The function does a traversal pattern check, resolves relative paths by joining with the workspace, normalizes `..` components, and checks `starts_with`. However, it does NOT check whether any intermediate component in the path is a symlink pointing outside the workspace. This is the first indication of the vulnerability.

### Step 2: Examining `normalize_path` at `src/security/path.rs:117-138`
```rust
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 function performs manual `..` stripping, then attempts `canonicalize()`. If `canonicalize()` fails (e.g., the final file doesn't exist yet), it falls back to the manually-normalized path. This is critical: for a path like `/workspace/symlink_to_etc/file.txt` where `file.txt` doesn't exist, `canonicalize()` fails, and the function returns `/workspace/symlink_to_etc/file.txt` which passes the `starts_with` check. When the actual file operation runs, the OS resolves through the symlink and writes to `/etc/file.txt`.

### Step 3: Tracing the data flow from user input to the sink
The filesystem tools are registered in `main.rs:166-171`:
```rust
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;
```

These tools are executed by the LLM agent loop in `src/agent/loop.rs:257-269`:
```rust
let tool_ctx = ToolContext::new()
    .with_channel(&msg.channel, &msg.chat_id)
    .with_workspace(&self.config.agents.defaults.workspace);

for tool_call in &response.tool_calls {
    let args: serde_json::Value =
        serde_json::from_str(&tool_call.arguments).unwrap_or_default();
    let result = tools.execute_with_context(&tool_call.name, args, &tool_ctx).await;
}
```

The `path` parameter in tool calls comes from the LLM's JSON arguments, which are derived from user input. This confirms user-controllable input reaches the file operation sink.

### Step 4: Identifying the three specific vulnerability classes

**A. Dangling Symlink Component Bypass (`src/security/path.rs:137`)**:
- `canonicalize()` on a non-existent path returns `Err`
- Code falls back to the manually-normalized path via `unwrap_or(normalized)`
- If an intermediate component is a symlink to outside the workspace, the manual path passes `starts_with`
- The actual file operation (`tokio::fs::read_to_string`, `tokio::fs::write`, etc.) resolves through the symlink, accessing files outside the workspace

**B. TOCTOU Between Validation and Use (`src/tools/filesystem.rs:82-84`)**:
- `resolve_path(path, ctx)` calls `validate_path_in_workspace(path, workspace)` and returns a `String` path
- The actual file operation (e.g., `tokio::fs::read_to_string(&full_path)`) occurs on line 84
- Between validation and use, an attacker or concurrent process can modify the filesystem (e.g., replace a directory with a symlink)
- The validated path string no longer reflects the actual filesystem state at the time of the file operation

**C. Hardlink Alias Bypass (`src/security/path.rs:137`)**:
- `canonicalize()` on a hardlink returns the path of the hardlink itself, not the target file's path
- A hardlink inside the workspace to a file outside the workspace passes `starts_with` because the hardlink path is within the workspace
- Reading/writing the hardlink accesses the content of the outside file, but `canonicalize()` cannot detect this because hardlinks are transparent at the path resolution level

### Step 5: Verifying the fix in the repository history
The fix is present in commit `2c9deef` (on the `main` branch, not the current analysis branch). The fix adds:

1. **`check_symlink_escape` function** (`src/security/path.rs` in `2c9deef`): Iterates through each path component beyond the workspace, canonicalizes existing components, and rejects if any symlink resolves outside the workspace. This addresses the dangling symlink component bypass.

2. **Workspace requirement in `resolve_path`** (`src/tools/filesystem.rs` in `2c9deef`): Changed from `if let Some(ref workspace) = ctx.workspace` to requiring a workspace via `ctx.workspace.as_ref().ok_or_else(...)`. This removes the fallback to unvalidated absolute paths when no workspace is set.

3. **Per-component canonicalization check**: The `check_symlink_escape` function checks each existing component in the path, preventing symlink-based escapes even when the final target doesn't exist.

### Step 6: Checking for additional bypass vectors
- The `contains_traversal_pattern` function (`src/security/path.rs:144-158`) checks for `..` and URL-encoded variants. It does NOT block symlinks or hardlinks because those are filesystem-level constructs, not string patterns.
- There is no `O_NOFOLLOW`-style protection or `AT_EACCESS` check in the file operations.
- The code does not use `openat()` with relative paths to prevent symlink races.

### Analysis Process
- Q1: Does user-controllable input influence the file path? → **YES** (`src/agent/loop.rs:257-269`, `src/tools/filesystem.rs:76-82`)
- Q2: Is the path canonicalized AND validated against a base directory? → **PARTIAL** (`src/security/path.rs:88-102`). Canonicalization is attempted but falls back to non-canonical path on failure. Intermediate symlink components are not checked.
- Q3: Is `basename()` used to strip directory components? → **NO**
- Q4: Is there an effective allowlist of permitted files? → **NO**
- Q5: Is the code in test/demo/dead/generated context? → **NO** — this is active production filesystem tool code
- Q6: Can `../` bypass filtering? → **YES** via symlink/hardlink aliases; also the manual `..` stripping in `normalize_path` may be bypassed by symlink races

→ Reached leaf: **TRUE_POSITIVE**

## 3. Verdict
**TRUE_POSITIVE**

**Key Evidence:**
- `src/security/path.rs:137`: `normalized.canonicalize().unwrap_or(normalized)` falls back to a non-canonical path when the target doesn't exist, allowing symlink component bypasses
- `src/security/path.rs:68-107`: `validate_path_in_workspace` does not check intermediate path components for symlinks escaping the workspace
- `src/tools/filesystem.rs:20-29`: When a workspace is configured, `resolve_path` validates and returns a path string, but the actual file operation occurs later, creating a TOCTOU window
- `src/security/path.rs:137`: `canonicalize()` on a hardlink returns the hardlink path itself, failing to detect hardlinks to files outside the workspace
- Commit `2c9deef` on `main` branch contains the fix (`check_symlink_escape` function and workspace requirement changes), confirming the vulnerability existed in the current codebase

## 4. Remediation Suggestions

**If TRUE_POSITIVE** (confirmed):
1. **Adopt `check_symlink_escape` from commit `2c9deef`**: Iterate through each path component beyond the workspace and canonicalize existing components to detect symlink escapes before the file operation.
2. **Require workspace configuration**: Remove the fallback that allows absolute paths when no workspace is configured (as done in `2c9deef` `src/tools/filesystem.rs`).
3. **Use `O_NOFOLLOW` or equivalent**: On Linux, open files with `O_NOFOLLOW` to prevent following symlinks at open time. In Rust with `tokio::fs`, consider using `std::fs::OpenOptions` with `custom_flags(O_NOFOLLOW)` where appropriate.
4. **Implement per-component `openat()` style traversal**: Instead of resolving the full path string and passing it to file operations, use `openat()` with relative paths to traverse each component under the workspace root, preventing symlink races.
5. **Add hardlink detection**: After the fix for symlinks, consider using `stat` to compare inode/device numbers of the opened file against the workspace root to detect hardlink aliases.
6. **Minimize TOCTOU window**: Perform validation and file operation atomically where possible, or re-validate the path immediately before the file operation.
