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

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).

Parameters:

  • target_path (String)

    file to replace

  • content (String)

    full file content



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.message})"
    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.

Parameters:

  • target_str (String)

    e.g. “v1:session_xxx/name”

  • context_root (String)

    absolute path to L2 context root

Returns:

  • (Hash)

    { path: String|nil, status: :ok|:dangling }

Raises:

  • MalformedTargetError, PathEscapeError, SymlinkRejectedError, PathResolutionError



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.message}"
  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.message}"
  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).

Parameters:

  • start_sid (String)
  • start_name (String)
  • context_root (String)
  • max_depth (Integer) (defaults to: DEFAULT_MAX_DEPTH)

Returns:

  • (Hash)

    { root:, nodes: […], warnings: […] }



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.

Raises:



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.message})"
  { target: target, status: :skipped, reason: 'malformed', path: nil }
rescue PathResolutionError => e
  warnings << "skip #{target}: path_resolution (#{e.message})"
  { 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