Root cause
Path & link handling
feat(tools): add pdf_read tool, feature-gated with --features tool-pdf
How AI contributed: New attack surfacePublished Mar 12, 2026 in
qhkm/zeptoclawAll 5 findings in this repository
Summary Workspace boundary enforcement currently has three related bypass risks. This issue tracks fixing all three in one pull request. Details R1 - Dangling Symlink Component Bypass · What happens: Path validation can miss dangling symlink components during traversal checks. · Why it matters: A symlink that is unresolved at validation time can later resolve to an external location. · Impact: Read and write operations may escape workspace boundaries. · Affected area: src/security/path.rs (check_symlink_escape). R2 - TOCTOU Between Validation and Use · What happens: The path is validated first, then used later for filesystem operations. · Why it matters: A concurrent filesystem change can swap path components after validation but before open/write. · Impact: Race-based workspace escape is possible. · Affected area: Filesystem and file-consuming tools that call validate_path_in_workspace before I/O. R3 - Hardlink Alias Bypass · What happens: A file inside workspace can be a hardlink to an inode outside the intended workspace trust boundary. · Why it matters: Prefix and symlink checks can pass while data access still mutates or reads external content. · Impact: Policy bypass for read/write operations. · Affected area: Any tool that reads or writes via validated paths. Risk Matrix ID: Risk: Severity: Likelihood: Impact R1: Dangling symlink component bypass: High: Medium: Workspace boundary escape for read/write R2: Validate/use TOCTOU race: High: Medium: Race-based boundary escape during file I/O R3: Hardlink alias bypass: Medium: Low-Medium: External inode read/write through in-workspace path PoC R1 - Dangling symlink component bypass 1. Create a symlink inside workspace pointing to a missing target. 2. Validate a path traversing that symlink. 3. Create the target directory outside workspace after validation. 4. Perform file operation and observe potential boundary escape if not fail-closed. R2 - TOCTOU between validation and use 1. Validate a candidate in-workspace path. 2. Before open/write, replace an intermediate component with a link to external location. 3. Continue with the file operation. 4. Observe boundary escape if operation trusts only stale validation result. R3 - Hardlink alias bypass 1. Place a hardlink inside workspace that points to an external inode. 2. Validate the in-workspace hardlink path. 3. Read or write through this path. 4. Observe external inode access through a path that appears in-scope. Impacts Unauthorized cross path boundary Credit @zpbrent Patch f50c17e11ae3e2d40c96730abac41974ef2ee2a8
The pdf_read tool added in src/tools/pdf_read.rs relied on validate_path_in_workspace, which canonicalised only components that already existed, so dangling symlinks, hardlink aliases, or a component replaced after the check could carry file operations past the workspace boundary. The fix hardens check_symlink_escape in src/security/path.rs and re-checks resolved paths via revalidate_path and check_hardlink_write.
Root cause
feat(tools): add pdf_read tool, feature-gated with --features tool-pdf
Fix
security: harden path validation against dangling symlink escapes (#278)
Case facts
diff --git a/src/tools/pdf_read.rs b/src/tools/pdf_read.rsnew file mode 100644index 0000000..54187d5--- /dev/null+++ b/src/tools/pdf_read.rs@@ -0,0 +1,255 @@+//! PDF text extraction tool.+//!+//! Always registered so the LLM knows it exists and can invoke it. Actual+//! extraction via lopdf requires `--features tool-pdf`. Without the feature+//! the tool returns a clear, actionable "requires tool-pdf feature" error+//! rather than silently failing, keeping the default binary at ~4MB.++use async_trait::async_trait;+use serde_json::{json, Value};+use std::path::PathBuf;++use crate::error::{Result, ZeptoError};+use crate::security::validate_path_in_workspace;++use super::{Tool, ToolContext};++/// Maximum PDF file size accepted before extraction (50 MB).+const MAX_PDF_BYTES: u64 = 50 * 1024 * 1024;++/// Default output character limit.+const DEFAULT_MAX_CHARS: usize = 50_000;++/// Maximum allowed `max_chars` value from LLM args.+const HARD_MAX_CHARS: usize = 200_000;++/// Extract plain text from a PDF file in the workspace.+///+/// The tool is always registered; extraction requires `--features tool-pdf`.+pub struct PdfReadTool {+ workspace: String,+}++impl PdfReadTool {+ /// Create a new `PdfReadTool` bound to `workspace`.+ pub fn new(workspace: String) -> Self {+ Self { workspace }+ }++ /// Resolve and validate `path` to an absolute, workspace-bound PDF path.+ ///+ /// Returns an error if:+ /// - The path escapes the workspace (path traversal).+ /// - The file does not have a `.pdf` extension.+ /// - The file does not exist.+ pub fn resolve_path(&self, path: &str) -> Result<PathBuf> {+ let safe = validate_path_in_workspace(path, &self.workspace)?;+ let p = safe.as_path();+ if p.extension().and_then(|e| e.to_str()) != Some("pdf") {+ return Err(ZeptoError::Tool(+ "Only .pdf files are supported".to_string(),+ ));+ }+ if !p.exists() {+ return Err(ZeptoError::Tool(format!("File not found: {path}")));+ }+ Ok(p.to_path_buf())+ }++ /// Truncate `text` to at most `max_chars` characters.+ ///+ /// Appends a `[TRUNCATED]` marker when truncation occurs.+ pub fn truncate_output(text: String, max_chars: usize) -> String {+ if text.len() <= max_chars {+ return text;+ }+ let mut s = text[..max_chars].to_string();+ s.push_str("\n[TRUNCATED] — output exceeded max_chars");+ s+ }++ /// Extract text from the PDF at `path` using lopdf.+ ///+ /// Requires `--features tool-pdf`. Without the feature this returns a+ /// clear error telling the user how to rebuild.+ #[cfg(feature = "tool-pdf")]+ fn extract_text(path: &std::path::Path) -> Result<String> {+ use lopdf::Document;+ let doc = Document::load(path)+ .map_err(|e| ZeptoError::Tool(format!("Failed to load PDF: {e}")))?;+ let mut text = String::new();+ for page_id in doc.page_iter() {+ if let Ok(page_text) = doc.extract_text(&[page_id.0]) {+ text.push_str(&page_text);+ text.push('\n');+ }+ }+ Ok(text)+ }++ #[cfg(not(feature = "tool-pdf"))]+ fn extract_text(_path: &std::path::Path) -> Result<String> {+ Err(ZeptoError::Tool(+ "PDF extraction requires the 'tool-pdf' build feature. \+ Rebuild with: cargo build --features tool-pdf"+ .to_string(),+ ))+ }+}++#[async_trait]+impl Tool for PdfReadTool {+ fn name(&self) -> &str {+ "pdf_read"+ }++ fn description(&self) -> &str {+ "Extract plain text from a PDF file in the workspace. \+ Returns all readable text content. \+ Image-only or encrypted PDFs may return empty results."+ }++ fn compact_description(&self) -> &str {+ "Extract plain text from a workspace PDF file."+ }++ fn parameters(&self) -> Value {+ json!({+ "type": "object",+ "required": ["path"],+ "properties": {+ "path": {+ "type": "string",+ "description": "Relative path to the PDF file within the workspace"+ },+ "max_chars": {+ "type": "integer",+ "description": "Maximum characters to return (default: 50000, max: 200000)",+ "default": DEFAULT_MAX_CHARS+ }+ }+ })+ }++ async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {+ let path_str = args["path"].as_str().unwrap_or("");+ if path_str.is_empty() {+ return Err(ZeptoError::Tool("Missing required argument: path".to_string()));+ }++ let max_chars = args["max_chars"]+ .as_u64()+ .map(|v| v as usize)+ .unwrap_or(DEFAULT_MAX_CHARS)+ .min(HARD_MAX_CHARS);++ let resolved = self.resolve_path(path_str)?;++ // Size guard before we do any I/O-heavy work.+ let meta = tokio::fs::metadata(&resolved).await.map_err(|e| {+ ZeptoError::Tool(format!("Cannot stat file: {e}"))+ })?;+ if meta.len() > MAX_PDF_BYTES {+ return Err(ZeptoError::Tool(format!(+ "PDF too large: {} bytes (max {}MB)",+ meta.len(),+ MAX_PDF_BYTES / 1024 / 1024+ )));+ }++ // Offload blocking lopdf I/O off the async thread.+ let text = tokio::task::spawn_blocking(move || Self::extract_text(&resolved))+ .await+ .map_err(|e| ZeptoError::Tool(format!("Task panicked: {e}")))??;++ if text.trim().is_empty() {+ return Ok(+ "No text content found. The PDF may be image-only or encrypted.".to_string(),+ );+ }++ Ok(Self::truncate_output(text, max_chars))+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use std::io::Write;+ use tempfile::TempDir;++ fn tool(workspace: &str) -> PdfReadTool {+ PdfReadTool::new(workspace.to_string())+ }++ #[test]+ fn test_rejects_path_outside_workspace() {+ let tmp = TempDir::new().unwrap();+ let t = tool(tmp.path().to_str().unwrap());+ let result = t.resolve_path("../../../etc/passwd");+ assert!(result.is_err(), "expected error for path traversal");+ }++ #[test]+ fn test_rejects_non_pdf_extension() {+ let tmp = TempDir::new().unwrap();+ // Create the file so it exists; extension check should still fire.+ let txt_path = tmp.path().join("document.txt");+ std::fs::File::create(&txt_path).unwrap();+ let t = tool(tmp.path().to_str().unwrap());+ let result = t.resolve_path("document.txt");+ assert!(result.is_err(), "expected error for non-pdf extension");+ let msg = format!("{}", result.unwrap_err());+ assert!(msg.contains(".pdf"), "error should mention .pdf: {msg}");+ }++ #[test]+ fn test_rejects_missing_file() {+ let tmp = TempDir::new().unwrap();+ let t = tool(tmp.path().to_str().unwrap());+ let result = t.resolve_path("missing.pdf");+ assert!(result.is_err(), "expected error for missing file");+ let msg = format!("{}", result.unwrap_err());+ assert!(+ msg.contains("not found") || msg.contains("missing"),+ "error should mention missing file: {msg}"+ );+ }++ #[test]+ fn test_accepts_valid_pdf_path() {+ let tmp = TempDir::new().unwrap();+ let pdf_path = tmp.path().join("invoice.pdf");+ std::fs::File::create(&pdf_path)+ .unwrap()+ .write_all(b"%PDF-1.4")+ .unwrap();+ let t = tool(tmp.path().to_str().unwrap());+ let result = t.resolve_path("invoice.pdf");+ assert!(result.is_ok(), "expected Ok for valid pdf path: {:?}", result);+ }++ #[test]+ fn test_truncate_output() {+ let long = "a".repeat(200_000);+ let result = PdfReadTool::truncate_output(long, 50_000);+ // Result must be longer than 50_000 (due to the truncation marker) but+ // no more than 50_100 characters of meaningful overhead.+ assert!(+ result.len() <= 50_100,+ "truncated output too long: {}",+ result.len()+ );+ assert!(+ result.contains("[TRUNCATED]"),+ "truncated output missing marker"+ );+ }++ #[test]+ fn test_truncate_output_short() {+ let short = "hello world".to_string();+ let result = PdfReadTool::truncate_output(short.clone(), 50_000);+ assert_eq!(result, short, "short strings should be returned unchanged");+ }+}diff --git a/src/security/path.rs b/src/security/path.rsindex 3f35d41..169112e 100644--- a/src/security/path.rs+++ b/src/security/path.rs@@ -161,22 +161,71 @@ fn check_symlink_escape(path: &Path, canonical_workspace: &Path) -> Result<()> {- // Only check components that exist on the filesystem- if current.exists() {- // Canonicalize to resolve any symlinks- if let Ok(canonical) = current.canonicalize() {- // Check if the canonical path is still within workspace- if !canonical.starts_with(canonical_workspace) {- log_audit_event(- AuditCategory::PathSecurity,- AuditSeverity::Critical,- "symlink_escape",- &format!(- "Symlink escape: '{}' resolves to '{}' outside workspace",- current.display(),- canonical.display()- ),- true,- );- return Err(ZeptoError::SecurityViolation(format!(- "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",- current.display(),- canonical.display()- )));+ match std::fs::symlink_metadata(¤t) {+ Ok(meta) => {+ if meta.file_type().is_symlink() {+ match current.canonicalize() {+ Ok(canonical) => {+ if !canonical.starts_with(canonical_workspace) {+ log_audit_event(+ AuditCategory::PathSecurity,+ AuditSeverity::Critical,+ "symlink_escape",+ &format!(+ "Symlink escape: '{}' resolves to '{}' outside workspace",+ current.display(),+ canonical.display()+ ),+ true,+ );+ return Err(ZeptoError::SecurityViolation(format!(+ "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",+ current.display(),+ canonical.display()+ )));+ }+ }+ Err(_) => {+ log_audit_event(+ AuditCategory::PathSecurity,+ AuditSeverity::Critical,+ "dangling_symlink",+ &format!(+ "Dangling symlink: '{}' cannot be resolved",+ current.display()+ ),+ true,+ );+ return Err(ZeptoError::SecurityViolation(format!(+ "Dangling symlink detected: '{}' target does not exist and cannot be validated",+ current.display()+ )));+ }+ }+ } else if meta.is_dir() {+ if let Ok(canonical) = current.canonicalize() {+ if !canonical.starts_with(canonical_workspace) {+ log_audit_event(+ AuditCategory::PathSecurity,+ AuditSeverity::Critical,+ "symlink_escape",+ &format!(+ "Symlink escape: '{}' resolves to '{}' outside workspace",+ current.display(),+ canonical.display()+ ),+ true,+ );+ return Err(ZeptoError::SecurityViolation(format!(+ "Symlink escape detected: '{}' resolves to '{}' which is outside workspace",+ current.display(),+ canonical.display()+ )));+ }+ }diff --git a/src/tools/filesystem.rs b/src/tools/filesystem.rsindex 1495c0d..2699ab9 100644--- a/src/tools/filesystem.rs+++ b/src/tools/filesystem.rs@@ -12 +12 @@ use crate::error::{Result, ZeptoError};-use crate::security::validate_path_in_workspace;+use crate::security::{check_hardlink_write, revalidate_path, validate_path_in_workspace};@@ -21 +21,3 @@ use super::{Tool, ToolCategory, ToolContext, ToolOutput};-fn resolve_path(path: &str, ctx: &ToolContext) -> Result<String> {+fn resolve_path(path: &str, ctx: &ToolContext) -> Result<(String, String)> {@@ -28 +30,4 @@ fn resolve_path(path: &str, ctx: &ToolContext) -> Result<String> {- Ok(safe_path.as_path().to_string_lossy().to_string())+ Ok((+ safe_path.as_path().to_string_lossy().to_string(),+ workspace.clone(),+ ))@@ -90 +95,4 @@ impl Tool for ReadFileTool {- let full_path = resolve_path(path, ctx)?;+ let (full_path, workspace) = resolve_path(path, ctx)?;++ revalidate_path(Path::new(&full_path), &workspace)?;@@ -168 +176,5 @@ impl Tool for WriteFileTool {- let full_path = resolve_path(path, ctx)?;+ let (full_path, workspace) = resolve_path(path, ctx)?;+ let full_path_ref = Path::new(&full_path);++ revalidate_path(full_path_ref, &workspace)?;@@ -171 +183 @@ impl Tool for WriteFileTool {- if let Some(parent) = Path::new(&full_path).parent() {+ if let Some(parent) = full_path_ref.parent() {@@ -178,0 +191,3 @@ impl Tool for WriteFileTool {+ check_hardlink_write(full_path_ref)?;+@@ -249 +264,4 @@ impl Tool for ListDirTool {- let full_path = resolve_path(path, ctx)?;+ let (full_path, workspace) = resolve_path(path, ctx)?;++ revalidate_path(Path::new(&full_path), &workspace)?;@@ -362 +380,5 @@ impl Tool for EditFileTool {- let full_path = resolve_path(path, ctx)?;+ let (full_path, workspace) = resolve_path(path, ctx)?;+ let full_path_ref = Path::new(&full_path);++ revalidate_path(full_path_ref, &workspace)?;@@ -380,0 +403,4 @@ impl Tool for EditFileTool {+ revalidate_path(full_path_ref, &workspace)?;+ check_hardlink_write(full_path_ref)?;+AI-assisted change c34e7e55e537709d94af9d36daf692bf1a99a492ce7f461f62debc6a062e599a · Fix 3491b0488689e28afbb0746bb5a58aa01e73c147f023ba9ca607fbd70b696bf9