Module: KairosMcp::ContextGraph
- Defined in:
- lib/kairos_mcp/context_graph.rb
Overview
ContextGraph: Phase 1 minimal mapping for L2 informed_by edges.
Design reference: docs/drafts/context_graph_l2_mapping_design_v2.1.md
Responsibilities:
- target string parse (TARGET_RE)
- resolve_target: path containment + symlink rejection (security)
- relations array validation (shape + type whitelist)
Non-responsibilities (by design, L2-evidential):
- durability invariants (write serialization, fsync sequence) — not L2's job
- semantic validation (whether informed_by claim is true) — defer to traverser
- edges.jsonl cache — Phase 2 if observation justifies
Defined Under Namespace
Classes: Error, InvalidFrontmatterError, MalformedRelationsError, MalformedTargetError, PathEscapeError, PathResolutionError, SymlinkRejectedError, UnsafeRelationValueError
Constant Summary collapse
- KNOWN_TYPES =
Recognized edge types in Phase 1. Unknown types are accepted on write (forward-compat) but skipped on traverse.
%w[informed_by].freeze
- TARGET_RE =
Target canonical regex (v2.1 §2). Permissive enough to reference both canonical session_ids (session_<8>_<6>_<8hex>) and the human-readable forms already on disk (e.g. coaching_insights_20260327, received_skills). Leading char restricted to [A-Za-z0-9_] to forbid dot/hyphen path tricks.
/\Av1:(?<sid>[A-Za-z0-9_][A-Za-z0-9_.\-]{0,127})\/(?<name>[A-Za-z0-9_][A-Za-z0-9_.\-]{0,127})\z/.freeze
- SAFE_VALUE_TYPES =
YAML value types permitted inside relations[] items. Anything outside this set is rejected on write to keep YAML.dump output free of anchors/aliases that downstream non-safe loaders could exploit.
[ String, Integer, Float, TrueClass, FalseClass, NilClass, Hash, Array, Time, Date ].freeze
- DEFAULT_MAX_DEPTH =
3- MAX_DEPTH_CLAMP =
16- MAX_VALUE_NESTING =
Recursion depth cap for assert_safe_value!. Pathological deeply-nested YAML in user-supplied frontmatter can otherwise blow Ruby’s call stack (SystemStackError) before validation completes. 32 is well above any legitimate usage and far below Ruby’s default stack limit.
32
Class Method Summary collapse
-
.assert_safe_value!(value, location, depth) ⇒ Object
Recursively check that a value tree contains only SAFE_VALUE_TYPES.
-
.atomic_write(target_path, content) ⇒ Object
Atomic write: create tempfile in same directory, write, rename.
-
.parse_target(target_str) ⇒ Object
Parse a target string into name.
-
.read_relations(md_path, warnings) ⇒ Object
Read a node’s relations[] for traversal.
-
.resolve_target(target_str, context_root) ⇒ Hash
Resolve target to an on-disk file path with full security checks.
-
.sanitize_max_depth(value) ⇒ Object
Coerce caller-supplied max_depth into [0, MAX_DEPTH_CLAMP].
-
.traverse_informed_by(start_sid:, start_name:, context_root:, max_depth: DEFAULT_MAX_DEPTH) ⇒ Hash
BFS traverse informed_by edges starting from (start_sid, start_name).
-
.validate_relations!(relations) ⇒ Object
Validate a relations[] array on the write path.
-
.verify_dangling_containment(candidate, root_real) ⇒ Object
Check that the parent directory of a missing target stays inside root.
-
.visit_node(target, context_root, warnings) ⇒ Object
Visit one node: resolve, classify status.
Class Method Details
.assert_safe_value!(value, location, depth) ⇒ Object
Recursively check that a value tree contains only SAFE_VALUE_TYPES. Bounded by MAX_VALUE_NESTING to prevent SystemStackError from pathological frontmatter input.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/kairos_mcp/context_graph.rb', line 156 def assert_safe_value!(value, location, depth) raise UnsafeRelationValueError, "value nesting exceeds MAX_VALUE_NESTING=#{MAX_VALUE_NESTING} at #{location}" if depth > MAX_VALUE_NESTING case value when Hash value.each do |k, v| assert_safe_value!(k, "#{location}.<key>", depth + 1) assert_safe_value!(v, "#{location}.#{k}", depth + 1) end when Array value.each_with_index { |v, i| assert_safe_value!(v, "#{location}[#{i}]", depth + 1) } else return if SAFE_VALUE_TYPES.any? { |t| value.is_a?(t) } raise UnsafeRelationValueError, "unsafe value type #{value.class} at #{location} (allowed: #{SAFE_VALUE_TYPES.map(&:name).join(', ')})" end end |
.atomic_write(target_path, content) ⇒ Object
Atomic write: create tempfile in same directory, write, rename. Replaces target with full content. Crash mid-write leaves target either pre- or post-rename (never truncated).
181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/kairos_mcp/context_graph.rb', line 181 def atomic_write(target_path, content) dir = File.dirname(target_path) FileUtils.mkdir_p(dir) unless File.directory?(dir) tempname = "#{File.basename(target_path)}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}" temp_path = File.join(dir, tempname) begin File.write(temp_path, content) File.rename(temp_path, target_path) ensure File.delete(temp_path) if File.exist?(temp_path) end end |
.parse_target(target_str) ⇒ Object
Parse a target string into name. Returns nil on mismatch.
65 66 67 68 69 70 71 72 |
# File 'lib/kairos_mcp/context_graph.rb', line 65 def parse_target(target_str) return nil unless target_str.is_a?(String) m = TARGET_RE.match(target_str) return nil unless m { sid: m[:sid], name: m[:name] } end |
.read_relations(md_path, warnings) ⇒ Object
Read a node’s relations[] for traversal. Returns [] on any read-side issue (parse fail, missing schema, unknown schema), appending a warning.
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/kairos_mcp/context_graph.rb', line 291 def read_relations(md_path, warnings) return [] unless md_path && File.exist?(md_path) content = File.read(md_path, encoding: 'UTF-8') m = content.match(/\A---\r?\n(.+?)\r?\n---\r?\n/m) return [] unless m begin # permitted_classes intentionally symmetric with write-side # SAFE_VALUE_TYPES (no Symbol). Legacy files containing Symbol values # parse-fail loudly here rather than feed asymmetric data into BFS. front = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {} rescue StandardError => e warnings << "skip relations of #{md_path}: parse_failed (#{e.})" return [] end unless front.is_a?(Hash) warnings << "skip relations of #{md_path}: frontmatter root is not a Hash (got #{front.class})" return [] end schema_v = front['relations_schema'] || front[:relations_schema] if schema_v && schema_v != 1 warnings << "skip relations of #{md_path}: unknown_schema_version=#{schema_v.inspect}" return [] end rels = front['relations'] || front[:relations] return [] unless rels.is_a?(Array) rels end |
.resolve_target(target_str, context_root) ⇒ Hash
Resolve target to an on-disk file path with full security checks.
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/kairos_mcp/context_graph.rb', line 80 def resolve_target(target_str, context_root) parsed = parse_target(target_str) raise MalformedTargetError, "target does not match canonical form: #{target_str.inspect}" unless parsed root_real = begin File.realpath(context_root) rescue Errno::ENOENT # context_root itself is missing — treat as resolution failure raise PathResolutionError, "context_root does not exist: #{context_root}" end candidate = File.join(root_real, parsed[:sid], parsed[:name], "#{parsed[:name]}.md") # Symlink rejection BEFORE realpath: lstat does not follow links, so # if the final component is a symlink we reject it here without # leaking the symlink's target into containment evaluation. lst = begin File.lstat(candidate) rescue Errno::ENOENT # Forward reference: target file does not exist yet. verify_dangling_containment(candidate, root_real) return { path: nil, status: :dangling } rescue SystemCallError => e raise PathResolutionError, "fs error stat'ing #{target_str}: #{e.}" end raise SymlinkRejectedError, "target final component is a symlink: #{candidate}" if lst.symlink? resolved = begin File.realpath(candidate) rescue SystemCallError => e raise PathResolutionError, "fs error resolving #{target_str}: #{e.}" end sep = File::SEPARATOR unless resolved == root_real || resolved.start_with?(root_real + sep) raise PathEscapeError, "resolved path escapes context_root: #{resolved}" end { path: resolved, status: :ok } end |
.sanitize_max_depth(value) ⇒ Object
Coerce caller-supplied max_depth into [0, MAX_DEPTH_CLAMP]. Non-Integer input falls back to DEFAULT_MAX_DEPTH (avoids ArgumentError / NoMethodError on string or nil from tool args).
265 266 267 268 269 |
# File 'lib/kairos_mcp/context_graph.rb', line 265 def sanitize_max_depth(value) n = Integer(value) rescue DEFAULT_MAX_DEPTH return 0 if n < 0 [n, MAX_DEPTH_CLAMP].min end |
.traverse_informed_by(start_sid:, start_name:, context_root:, max_depth: DEFAULT_MAX_DEPTH) ⇒ Hash
BFS traverse informed_by edges starting from (start_sid, start_name).
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/kairos_mcp/context_graph.rb', line 223 def traverse_informed_by(start_sid:, start_name:, context_root:, max_depth: DEFAULT_MAX_DEPTH) depth_limit = sanitize_max_depth(max_depth) root_target = "v1:#{start_sid}/#{start_name}" result = { root: root_target, nodes: [], warnings: [] } visited = {} queue = [[root_target, 0]] until queue.empty? target, depth = queue.shift next if visited.key?(target) visited[target] = true node = visit_node(target, context_root, result[:warnings]) node[:depth] = depth result[:nodes] << node next if node[:status] != :ok next if depth >= depth_limit outgoing = read_relations(node[:path], result[:warnings]) outgoing.each_with_index do |edge, idx| unless edge.is_a?(Hash) result[:warnings] << "skip relations item #{idx} of #{node[:target]}: not a Hash" next end next unless edge['type'] == 'informed_by' || edge[:type] == 'informed_by' edge_target = edge['target'] || edge[:target] next unless edge_target.is_a?(String) next if visited.key?(edge_target) queue << [edge_target, depth + 1] end end result end |
.validate_relations!(relations) ⇒ Object
Validate a relations[] array on the write path. Mutates nothing. Returns nil on success, raises on the first violation.
Rules (v2.1 §1.1, §4.2):
- relations is Array
- each item is Hash with String type and String target
- target matches TARGET_RE
- all values are SAFE_VALUE_TYPES (recursively)
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/kairos_mcp/context_graph.rb', line 130 def validate_relations!(relations) raise MalformedRelationsError, 'relations must be an Array' unless relations.is_a?(Array) relations.each_with_index do |item, idx| raise MalformedRelationsError, "relations[#{idx}] must be a Hash" unless item.is_a?(Hash) type = item['type'] || item[:type] target = item['target'] || item[:target] raise MalformedRelationsError, "relations[#{idx}] missing 'type'" if type.nil? raise MalformedRelationsError, "relations[#{idx}] missing 'target'" if target.nil? raise MalformedRelationsError, "relations[#{idx}].type must be String" unless type.is_a?(String) raise MalformedRelationsError, "relations[#{idx}].target must be String" unless target.is_a?(String) raise MalformedTargetError, "relations[#{idx}].target does not match canonical form: #{target.inspect}" unless TARGET_RE.match?(target) item.each do |k, v| assert_safe_value!(v, "relations[#{idx}].#{k}", 0) end end nil end |
.verify_dangling_containment(candidate, root_real) ⇒ Object
Check that the parent directory of a missing target stays inside root. Used for the dangling (ENOENT) branch.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/kairos_mcp/context_graph.rb', line 198 def verify_dangling_containment(candidate, root_real) # Walk up until we find an existing ancestor ancestor = candidate until File.exist?(ancestor) parent = File.dirname(ancestor) break if parent == ancestor # reached fs root ancestor = parent end return unless File.exist?(ancestor) ancestor_real = File.realpath(ancestor) sep = File::SEPARATOR return if ancestor_real == root_real || ancestor_real.start_with?(root_real + sep) raise PathEscapeError, "dangling target's nearest ancestor escapes context_root: #{ancestor_real}" end |
.visit_node(target, context_root, warnings) ⇒ Object
Visit one node: resolve, classify status. Returns node hash.
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 |
# File 'lib/kairos_mcp/context_graph.rb', line 272 def visit_node(target, context_root, warnings) resolved = resolve_target(target, context_root) if resolved[:status] == :dangling return { target: target, status: :dangling, reason: nil, path: nil } end { target: target, status: :ok, reason: nil, path: resolved[:path] } rescue MalformedTargetError => e warnings << "skip #{target}: malformed (#{e.})" { target: target, status: :skipped, reason: 'malformed', path: nil } rescue PathResolutionError => e warnings << "skip #{target}: path_resolution (#{e.})" { target: target, status: :skipped, reason: 'path_resolution', path: nil } # PathEscapeError and SymlinkRejectedError intentionally NOT caught: # those are hard fails on both write and read paths (v2.1 §3.1). end |