summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/tool/files/sandbox.rb
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-31 23:10:45 +0900
committerAdam Malczewski <[email protected]>2026-03-31 23:10:45 +0900
commit57c56daf5906442dacc15951c9b3405f89309839 (patch)
treee76890119c0e47acb48f8585222b7a2f5e22df56 /lib/dispatch/tool/files/sandbox.rb
parent25488d32336e05b69a41391cc7b5153478d3cc8a (diff)
downloaddispatch-tool-files-dev.tar.gz
dispatch-tool-files-dev.zip
impdev
Diffstat (limited to 'lib/dispatch/tool/files/sandbox.rb')
-rw-r--r--lib/dispatch/tool/files/sandbox.rb69
1 files changed, 69 insertions, 0 deletions
diff --git a/lib/dispatch/tool/files/sandbox.rb b/lib/dispatch/tool/files/sandbox.rb
new file mode 100644
index 0000000..6ceccb1
--- /dev/null
+++ b/lib/dispatch/tool/files/sandbox.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ module Sandbox
+ module_function
+
+ def resolve_path(path, worktree_path:)
+ worktree_real = File.realpath(worktree_path)
+
+ # Join the path against the worktree root
+ joined = File.expand_path(path, worktree_real)
+
+ # Check the expanded (logical) path first — catches ../traversal and absolute paths
+ unless joined.start_with?("#{worktree_real}/") || joined == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox"
+ end
+
+ # For existing paths, resolve symlinks and verify the real path
+ if File.exist?(joined)
+ real = File.realpath(joined)
+
+ unless real.start_with?("#{worktree_real}/") || real == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)"
+ end
+
+ real
+ else
+ # For non-existent paths, resolve as much of the existing prefix as possible
+ # to catch symlinked directory components pointing outside the worktree
+ parts = joined.delete_prefix("#{worktree_real}/").split("/")
+ current = worktree_real
+
+ parts.each_with_index do |part, i|
+ candidate = File.join(current, part)
+
+ if File.symlink?(candidate)
+ real_target = File.realpath(candidate)
+
+ unless real_target.start_with?("#{worktree_real}/") || real_target == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)"
+ end
+
+ current = real_target
+ elsif File.exist?(candidate)
+ current = File.realpath(candidate)
+ else
+ # Remainder of the path doesn't exist on disk — that's fine, return the logical path
+ remaining = parts[(i + 1)..]
+
+ return remaining.empty? ? candidate : File.join(candidate, *remaining)
+ end
+ end
+
+ current
+ end
+ end
+
+ def within_worktree?(path, worktree_path:)
+ resolve_path(path, worktree_path:)
+ true
+ rescue SandboxError
+ false
+ end
+ end
+ end
+ end
+end