Module: KairosMcp::PathContainment

Defined in:
lib/kairos_mcp/path_containment.rb

Overview

Path guards for store-rooted file access.

The L1 (knowledge) and L2 (context) layers address files by joining caller-supplied values onto a store root. Two different shapes of value arrive there, and they need two different guards:

safe_segment?  a knowledge name, a context name, a session id — each is
             exactly one directory below the store root. "." and ".."
             are not names; they collapse back onto the root, which is
             how a delete addressed "inside" the store can take the
             store itself.

contained?     a whole path, including the multi-segment tail of a URI
             (scripts/sub/run.py) or of an HTTP request. Here segments
             are legitimately many, so the guard is where the joined
             path resolves.

Invariant: every path the storage layers open, create, move, or delete resolves strictly inside the store root it was addressed against, and every caller-supplied name denotes one directory rather than a route.

Constant Summary collapse

40

Class Method Summary collapse

Class Method Details

.contained?(base, path) ⇒ Boolean

True if path resolves to a location at or under the real path of base.

Resolution walks the literal path, never a lexically pre-collapsed one. That ordering is the whole point: File.expand_path("store/link/../x") yields "store/x" and cancels the symlink, while the kernel traverses link first and lands wherever it points. Collapsing before resolving would make this predicate answer about a path nobody opens.

A path that does not exist yet (create paths) is resolved as far as it does exist, and the missing tail is appended. A ".." surviving in that tail means the resolution never got to interpret it, so the result is refused rather than collapsed.

Parameters:

  • base (String)

    store root

  • path (String)

    candidate path built by joining onto base

Returns:

  • (Boolean)


61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/kairos_mcp/path_containment.rb', line 61

def contained?(base, path)
  base_real = File.realpath(base)
  target = resolve_as_far_as_it_exists(path)

  # An unresolved ".." in the tail would let a lexical prefix match succeed
  # on a string the kernel would resolve elsewhere.
  return false if target.split(File::SEPARATOR).include?('..')

  prefix = base_real.end_with?(File::SEPARATOR) ? base_real : base_real + File::SEPARATOR
  target == base_real || target.start_with?(prefix)
rescue SystemCallError, ArgumentError, TypeError
  # Missing or unreadable base, symlink loop, NUL byte, non-string input:
  # fail closed rather than raise into a caller expecting a boolean.
  false
end

.safe_segment?(segment) ⇒ Boolean

True if segment is a single, ordinary path component.

Rejects the empty string, "." and ".." (both resolve to a directory the caller did not name), anything containing a separator (which would make the value a route rather than a name), an absolute path, and a NUL byte (which raises out of every File method rather than returning false).

Parameters:

  • segment (Object)

    caller-supplied name / session id

Returns:

  • (Boolean)


36
37
38
39
40
41
42
43
# File 'lib/kairos_mcp/path_containment.rb', line 36

def safe_segment?(segment)
  return false unless segment.is_a?(String)
  return false if segment.empty? || segment == '.' || segment == '..'
  return false if segment.include?(File::SEPARATOR) || segment.include?("\0")
  return false if File::ALT_SEPARATOR && segment.include?(File::ALT_SEPARATOR)

  !segment.start_with?(File::SEPARATOR)
end