{"record":{"id":"e4f11719cc76033d","repo":"can1357/oh-my-pi","slug":"unsafe-scheme-path-absolute-or-traversal-e4f117","errorCode":null,"errorMessage":"Unsafe #{scheme}:// path (absolute or traversal): #{path}","messagePattern":"Unsafe #(.+?):// path \\(absolute or traversal\\): #(.+?)","errorType":"exception","errorClass":"RuntimeError","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/eval/rb/prelude.rb","lineNumber":48,"sourceCode":"  def __omp_resolve_path(path)\n    return path unless path.is_a?(String)\n    m = path.match(%r{\\A([a-z][a-z0-9+.\\-]*)://(.*)\\z}i)\n    return path unless m\n    scheme = m[1].downcase\n    roots =\n      begin\n        raw = ENV[\"PI_EVAL_LOCAL_ROOTS\"]\n        raw && !raw.empty? ? JSON.parse(raw) : {}\n      rescue StandardError\n        {}\n      end\n    root = roots.is_a?(Hash) ? roots[scheme] : nil\n    raise \"Protocol paths are not supported by this helper: #{path}\" if root.nil? || root.to_s.empty?\n    relative = __omp_url_decode(m[2].tr(\"\\\\\", \"/\"))\n    root_path = File.absolute_path(root.to_s)\n    return root_path if relative.empty?\n    if relative.start_with?(\"/\") || relative.split(\"/\").include?(\"..\")\n      raise \"Unsafe #{scheme}:// path (absolute or traversal): #{path}\"\n    end\n    resolved = File.absolute_path(File.join(root_path, relative))\n    unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)\n      raise \"#{scheme}:// path escapes its root: #{path}\"\n    end\n    resolved\n  end\n\n  # -------------------------------------------------------------------------\n  # Display + status\n  # -------------------------------------------------------------------------\n\n  def display(value)\n    __omp_present(value, \"display\")\n    nil\n  end\n\n  # Emit a base64 image as a display output. `mime_type` is \"image/png\" (default)","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/eval/rb/prelude.rb#L30-L66","documentation":"`__omp_resolve_path` is the prelude's scheme:// path rewriter used by `read`/`write`: paths like `local://foo/bar.txt` are mapped onto an on-disk root injected via PI_EVAL_LOCAL_ROOTS. This error is the sanitizer's hard stop — it throws when the portion after `scheme://` looks like an absolute path (`/etc/passwd`) or contains a `..` segment, either of which could read or write outside the configured root.","triggerScenarios":"Calling `read(\"local:///etc/passwd\")` or `read(\"local://../../secrets.txt\")` (or the equivalent via `write`), or any path where percent-decoding (after backslash-to-slash normalization) yields a leading `/` or a `..` segment, e.g. `read(\"local://%2e%2e/x\")`.","commonSituations":"An LLM-generated or user-supplied path keeps its absolute form after being written as `scheme://` + path; string concatenation like `\"local://\" + user_path` where `user_path` starts with `/`; traversal attempts embedded in encoded segments (`%2e%2e`); joining with `File.join(root, \"/abs\")`-style inputs.","solutions":["Strip any leading slashes and `.`/`..` segments from the path portion before passing it, e.g. `path.delete_prefix('/').split('/').reject { |s| s == '..' || s == '.' }`","Resolve the path yourself relative to the root declared in PI_EVAL_LOCAL_ROOTS and pass only the clean relative portion","If the file genuinely lives outside the root, read/write it via a plain (non-scheme) path if permitted, or extend PI_EVAL_LOCAL_ROOTS to a root that contains it","URL-encode deliberately if the segment is meant literally, but note `..` and leading `/` are rejected after decoding, so encoding cannot bypass this check"],"exampleFix":"// before\nread(\"local://../../etc/passwd\")\n// after\nsafe = \"../../etc/passwd\".split('/').reject { |p| p == '..' || p == '.' || p.empty? }\nread(\"local://#{safe.join('/')}\")","handlingStrategy":"validation","validationCode":"def scheme_path_safe?(path)\n  return true unless path.is_a?(String)\n  m = path.match(/\\A([a-z][a-z0-9+.\\-]*):\\/\\/(.*)\\z/i)\n  return true unless m\n  rel = m[2].tr(\"\\\\\", \"/\").gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack(\"H2\") }\n  !rel.start_with?(\"/\") && !rel.split(\"/\").include?(\"..\")\nend\n# call: read(path) only if scheme_path_safe?(path)","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Never concatenate \"scheme://\" with user/agent-supplied absolute paths — normalize to a relative path first","Reject or sanitize any segment equal to '..' and strip leading slashes before forming scheme paths","Remember percent-encoding is decoded before checks — do not rely on %2e%2e to bypass validation","Keep PI_EVAL_LOCAL_ROOTS pointing at a dedicated sandbox directory"],"tags":["ruby","path-traversal","security","validation"],"backgroundTag":"path-traversal-blocked","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}