# Vulnerability Verification Report

## 1. Summary
- **Vulnerability Type**: Path Traversal (CWE-22) — Dangling Symlink Component Bypass / TOCTOU Between Validation and Use / Hardlink Alias Bypass
- **Flagged Location**: `src/security/path.rs:68` (`validate_path_in_workspace`) together with the consumer `src/tools/filesystem.rs:20` (`resolve_path`) and the file-operation sinks at `src/tools/filesystem.rs:84`, `:162`, `:226`, `:332`.
- **Vulnerability Description**: ZeptoClaw prior to 0.7.6 validates file paths only by string-normalizing `.`/`..` and attempting `canonicalize()`. This can be bypassed by (a) a dangling-symlink component that causes `canonicalize()` to fail so the un-canonicalized (symlink-bearing) path is used, (b) a TOCTOU window between validation and the actual `tokio::fs::*` open/write, and (c) a hardlink alias inside the workspace whose inode points outside.

## 2. Analysis Logic

### Step 1: Examining the validation sink at `src/security/path.rs:68`
Read `src/security/path.rs` lines 1-159. The validator is:

```rust
pub fn validate_path_in_workspace(path: &str, workspace: &str) -> Result<SafePath> {
    if contains_traversal_pattern(path) {
        return Err(PicoError::SecurityViolation(...));
    }
    let workspace_path = Path::new(workspace);
    let target_path = Path::new(path);
    let resolved_path = if target_path.is_absolute() {
        target_path.to_path_buf()
    } else {
        workspace_path.join(target_path)
    };
    let normalized_path = normalize_path(&resolved_path);
    let canonical_workspace = workspace_path
        .canonicalize()
        .unwrap_or_else(|_| normalize_path(workspace_path));
    if !normalized_path.starts_with(&canonical_workspace) {
        return Err(PicoError::SecurityViolation(...));
    }
    Ok(SafePath { path: normalized_path })
}
```

Key observation: the only mechanism that resolves symlinks is `Path::canonicalize()`, and it is invoked inside `normalize_path`:

```rust
fn normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::ParentDir => { normalized.pop(); }
            Component::CurDir => {}
            _ => { normalized.push(component); }
        }
    }
    normalized.canonicalize().unwrap_or(normalized)   // <-- falls back to un-canonicalized
}
```

`canonicalize()` follows symlinks **only when every component resolves to an existing target**. If the final component (or any intermediate symlink target) does not exist, `canonicalize()` returns `Err`, and `unwrap_or(normalized)` returns the path **with the symlink component left in place**. `contains_traversal_pattern` (path.rs:144-158) only inspects the raw string for `..`/`%2e%2e` literals; it never inspects symlink/hardlink semantics.

Analysis: The validator depends on `canonicalize()` succeeding. Any path whose final component does not yet exist (e.g., a file the tool is about to create, or a dangling symlink) returns the non-resolved string, which is then compared with `starts_with(canonical_workspace)`. The string starts_with check passes whenever the literal prefix matches the workspace — even when an intermediate symlink component redirects outside.

Next: trace who consumes `SafePath` and how the actual file operation is performed.

### Step 2: Tracing the consumer in `src/tools/filesystem.rs:20`
Read `src/tools/filesystem.rs` lines 1-30 and 76-87.

```rust
fn resolve_path(path: &str, ctx: &ToolContext) -> Result<String> {
    if let Some(ref workspace) = ctx.workspace {
        let safe_path = validate_path_in_workspace(path, workspace)?;
        Ok(safe_path.as_path().to_string_lossy().to_string())
    } else {
        Ok(path.to_string())   // no workspace => arbitrary path
    }
}
```

And the read sink:

```rust
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)?;          // validated *string*
    tokio::fs::read_to_string(&full_path).await ...    // open happens *after* validation
}
```

The same pattern is used for `WriteFileTool` (filesystem.rs:151-164), `ListDirTool` (filesystem.rs:224-228), and `EditFileTool` (filesystem.rs:329-355). In every case the flow is: (1) compute `full_path` string from validator, (2) call `tokio::fs::*` with that string. Two structural flaws follow:

A. **Dangling Symlink Component Bypass.** `validate_path_in_workspace` calls `canonicalize()` which fails when the final target does not exist. The fallback returns the path containing the unresolved symlink component. The `starts_with(canonical_workspace)` check then passes because the symlink itself lives inside the workspace. The subsequent `tokio::fs::write(&full_path, content)` follows the symlink and writes outside the workspace. Concretely: place `workspace/lnk -> /etc` (a symlink to a directory). A request `{"path": "lnk/zeptoclaw_evil.txt"}` — `lnk` exists, `zeptoclaw_evil.txt` does not, so `canonicalize()` fails, the un-resolved `workspace/lnk/zeptoclaw_evil.txt` is returned, `starts_with(workspace)` is true, and `tokio::fs::write` creates `/etc/zeptoclaw_evil.txt` through the symlink. `contains_traversal_pattern` does not fire because there is no `..` literal.

B. **TOCTOU Between Validation and Use.** `validate_path_in_workspace` returns a `SafePath` that is converted back to a `String` and re-resolved by `tokio::fs::*` later. There is no file-descriptor hand-off, no `O_NOFOLLOW`, no `openat2`-style RESOLVE_BENEATH, no `flock` between validate and use. Between `resolve_path(...)` returning and `tokio::fs::read_to_string(&full_path)` opening the file, the path component on disk can change. An attacker (or a second tool call from the LLM in the same session, which is the documented threat model — see Step 4) can replace a regular file inside the workspace with a symlink to `/etc/passwd` after validation passes and before the read executes; the read then discloses the out-of-workspace file.

C. **Hardlink Alias Bypass.** A hardlink inside the workspace (created by `ln /etc/passwd workspace/alias`) appears to `canonicalize()` as a regular file — its path does not change because hardlinks share an inode but have no separate link target. `starts_with(canonical_workspace)` is satisfied. `tokio::fs::read_to_string("workspace/alias")` reads the out-of-workspace inode. The validator has no inode/dev check, so hardlink aliases are undetected.

Next: confirm that the `path` argument is user-controllable (entry-point reachability).

### Step 3: Tracing path origin to user input via the agent loop
Read `src/agent/loop.rs:240-295`.

```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 = match tools
        .execute_with_context(&tool_call.name, args, &tool_ctx)
        .await
    { ... }
}
```

`tool_call.arguments` is generated by the LLM provider from the conversation. The conversation is driven by an inbound user message (`msg.channel`/`msg.chat_id` come from the Telegram/CLI channel — see `src/channels/telegram.rs` and `src/channels/manager.rs`). The LLM is instructed to emit tool calls whose `path` argument is the user's requested target, so an attacker crafting a prompt can fully control the `path` string reaching `ReadFileTool::execute` / `WriteFileTool::execute` / `ListDirTool::execute` / `EditFileTool::execute`. This satisfies decision-tree question 1 (user-controllable input influences the file path).

Next: verify the validator never re-checks at open time and never uses an atomic open-then-fstat pattern.

### Step 4: Confirming no atomic open / no `O_NOFOLLOW` / no inode check anywhere in the project
Grep across the entire `src` tree for `symlink`, `hardlink`, `inode`, `O_NOFOLLOW`, `symlink_metadata`, `metadata`, `openat`, `canonicalize`:

- `src/security/path.rs:93,137` — only `canonicalize()`, with the failing fallback already analysed.
- `src/tools/shell.rs:178-181` — `canonicalize` used in a *shell-tool test*, unrelated to filesystem tool validation.
- No occurrences of `symlink_metadata`, `metadata(`, `O_NOFOLLOW`, `openat`, `FileType::is_symlink` in the validator or in `filesystem.rs` (the only `is_symlink` use is at `src/tools/filesystem.rs:242`, and it is purely cosmetic — it just appends `@` to the listing output; it does not gate any access).

Conclusion: the codebase has no defense-in-depth against symlink/hardlink/TOCTOU. The single canonicalize call is the only mitigation, and it is non-atomic and fallible.

### Step 5: Ruling out test/demo/dead-code context
`ReadFileTool`, `WriteFileTool`, `ListDirTool`, `EditFileTool` are registered in `src/tools/registry.rs` and invoked from `src/agent/loop.rs:267` (`execute_with_context`) on every LLM tool call. They are production tools reachable from the Telegram and CLI channels. The validator is non-test code exported from `src/lib.rs:24` and `src/security/mod.rs:8`. Not test/demo/dead code.

### Analysis Process
- Q1: Does user-controllable input influence the file path? → YES. `src/agent/loop.rs:264-268` parses `tool_call.arguments` (LLM-generated from user prompts) and passes it as `args` to the tool; `src/tools/filesystem.rs:77-82` extracts `args["path"]`.
- Q2: Is the path canonicalized AND validated against a base directory? → PARTIAL / FAILS. `src/security/path.rs:88,137` calls `canonicalize()` but falls back to the un-canonicalized path on failure (`unwrap_or(normalized)`), so non-existent targets (dangling-symlink case) are not canonicalized; `starts_with` is then a literal string check that an in-workspace symlink component satisfies.
- Q3: Is `basename()`/`getName()` used to strip directory components? → NO. The full user-supplied path (including subdirectories and symlink components) is preserved.
- Q4: Is there an effective allowlist of permitted files? → NO. Only a prefix check on a possibly-un-canonicalized path.
- Q5: Is the code in test/demo/dead/generated context? → NO. Production tools reachable from `src/agent/loop.rs:267`.
- → Reached leaf: TRUE_POSITIVE.

Additional corroboration mapping to the CVE's three named bypasses:
- **Dangling Symlink Component Bypass** — `src/security/path.rs:137` `normalized.canonicalize().unwrap_or(normalized)`.
- **TOCTOU Between Validation and Use** — validation at `src/tools/filesystem.rs:23` returns a string; the open happens later at `:84/:162/:226/:332` with no shared file descriptor or lock.
- **Hardlink Alias Bypass** — no `metadata().file_type().is_symlink()`/inode-vs-workspace-dev check anywhere in `src/security/path.rs`; `canonicalize()` does not alter hardlink paths.

## 3. Verdict

**TRUE_POSITIVE**

**Key Evidence:**
- `src/security/path.rs:117-138` — `normalize_path` falls back to the un-canonicalized path when `canonicalize()` fails, allowing an in-workspace dangling symlink component to pass `starts_with(canonical_workspace)` at `:97` while resolving outside at open time.
- `src/tools/filesystem.rs:20-29` and `:76-87` — validation produces a `String` that is later passed to `tokio::fs::read_to_string`/`write`/`read_dir` with no atomic open, no `O_NOFOLLOW`, no `symlink_metadata` check, no inode/dev comparison — a textbook TOCTOU window.
- `src/agent/loop.rs:257-268` — `tool_call.arguments` (derived from the user-driven LLM conversation) flow directly into the tool `args`, so the `path` field is attacker-controllable; combined with the absence of any hardlink detection in `src/security/path.rs`, all three CVE-named bypasses are reachable.

## 4. Remediation Suggestions
- Replace string-based validation with an atomic, descriptor-based open. Use `tokio::fs::OpenOptions` with `std::os::unix::fs::OpenOptionsExt` and `O_NOFOLLOW` (and ideally `O_CLOEXEC`), open the leaf directly, then `fstat` and verify the resolved path is still within the workspace via `/proc/self/fd/<fd>` readlink on Linux. This closes the TOCTOU window.
- Walk each component of the requested path with `symlink_metadata` and reject if any component (other than the workspace root) is a symlink. This eliminates the Dangling Symlink Component Bypass without relying on `canonicalize()` succeeding.
- For the Hardlink Alias Bypass, after opening the file compare the file's `st_dev`/`st_ino` against the workspace directory's `st_dev` and reject files whose inode is not on the same device as the workspace, or disallow hardlinks by setting the workspace directory's link count policy / using a separate filesystem.
- Re-canonicalize the *opened* file descriptor's path (not the input string) and re-check `starts_with(canonical_workspace)` on that resolved path, so even post-open symlink swaps are detected.
- For `write_file` specifically, do not use `create_dir_all(parent)` blindly on a validated-but-untrusted path; perform the same component-walk on each parent directory before creating it, otherwise a symlinked parent directory can be auto-created/used to escape.
- Add regression tests covering: in-workspace symlink to outside directory + write through it; in-workspace hardlink to `/etc/passwd` + read; rapid swap of regular file → symlink between validate and use. The existing tests in `src/security/path.rs:160-335` cover only literal `..`/`%2e%2e` traversal and do not exercise symlink or hardlink cases, which is why these bypasses slipped through.
