lex-mesh 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75ab8e1dbc78d725a92257640520bfda3f3acb36ae1dc1f1315cc7e8453ca467
4
- data.tar.gz: 269027a72a18770e8bf0338fa7c2941bc354546ffc6157bd2533fc258b054baf
3
+ metadata.gz: 650958df48e1ad4fa390b3fecb3ab2817dc535c279449dc4ce114e92f59cfff2
4
+ data.tar.gz: ff13ef5a0e18147daa15b72579de24f3691bec50f5f124627d2b503208cfba82
5
5
  SHA512:
6
- metadata.gz: 78afa9c435f105a2c18b107d14de0c6d4b9c8850c5181ce394aafe46613c70dedccb24b5b19dbf5ee71f2fa71657905e656d2d4eb916f3429446f0c61398a46d
7
- data.tar.gz: 766eb42fd99abaa287af0cd1f48b8daba505f9e4d20dccb927e748cb9734460839915ba2e8513aa8df5bf28817f032f230e08e5bf775f3330d9e4f34d48f47db
6
+ metadata.gz: fc982147fb2a22e4a5bb40fa8f7a767e87aed754f59474d4937059a831adcfeae6b4f4e4c9add47359c3b6a69200a0cdea21c1bafd1b00dfd74501c93415eb40
7
+ data.tar.gz: e34a1a0d9ae8481bb40949bda0e47d6e1647856777214d133acf10c121b8e70962bb85c10fda16120b9dd7bc958828fc20a73851ab259c89fa813d7df2236f26
data/Gemfile CHANGED
@@ -4,5 +4,7 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ gem 'base64'
8
+ gem 'ed25519', '~> 1.3'
7
9
  gem 'rspec', '~> 3.13'
8
10
  gem 'rubocop', '~> 1.75', require: false
data/lex-mesh.gemspec CHANGED
@@ -25,4 +25,7 @@ Gem::Specification.new do |spec|
25
25
  Dir.glob('{lib,spec}/**/*') + %w[lex-mesh.gemspec Gemfile]
26
26
  end
27
27
  spec.require_paths = ['lib']
28
+
29
+ spec.add_dependency 'base64'
30
+ spec.add_dependency 'ed25519', '~> 1.3'
28
31
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Actor
9
+ class Gossip < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Mesh::Runners::Mesh
12
+ end
13
+
14
+ def runner_function
15
+ :publish_gossip
16
+ end
17
+
18
+ def time
19
+ 15
20
+ end
21
+
22
+ def use_runner?
23
+ true
24
+ end
25
+
26
+ def check_subtask?
27
+ false
28
+ end
29
+
30
+ def generate_task?
31
+ false
32
+ end
33
+
34
+ def args
35
+ {}
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Helpers
9
+ class Delegation
10
+ CONSENT_LEVELS = %i[read execute admin].freeze
11
+
12
+ attr_reader :delegations
13
+
14
+ def initialize(max_depth: 3, max_active_per_agent: 10)
15
+ @delegations = {}
16
+ @agent_delegations = Hash.new { |h, k| h[k] = [] }
17
+ @max_depth = max_depth
18
+ @max_active_per_agent = max_active_per_agent
19
+ end
20
+
21
+ def create(from:, to:, task_context:, consent_level:, parent_delegation_id: nil)
22
+ depth = compute_depth(parent_delegation_id)
23
+ return { error: :max_depth_exceeded } if depth >= @max_depth
24
+
25
+ if parent_delegation_id
26
+ parent = find(parent_delegation_id)
27
+ return { error: :consent_escalation } if parent && CONSENT_LEVELS.index(consent_level) > CONSENT_LEVELS.index(parent[:consent_level])
28
+ end
29
+
30
+ active_count = (@agent_delegations[from] || []).count do |id|
31
+ @delegations[id]&.fetch(:status, nil) == :active
32
+ end
33
+ return { error: :max_active_exceeded } if active_count >= @max_active_per_agent
34
+
35
+ id = "del-#{SecureRandom.uuid}"
36
+ record = {
37
+ delegation_id: id,
38
+ from_agent_id: from,
39
+ to_agent_id: to,
40
+ task_context: task_context,
41
+ consent_level: consent_level,
42
+ parent_delegation_id: parent_delegation_id,
43
+ depth: depth,
44
+ status: :active,
45
+ created_at: Time.now.utc,
46
+ completed_at: nil
47
+ }
48
+ @delegations[id] = record
49
+ @agent_delegations[from] << id
50
+ @agent_delegations[to] << id
51
+ record
52
+ end
53
+
54
+ def complete(delegation_id)
55
+ record = @delegations[delegation_id]
56
+ return nil unless record && record[:status] == :active
57
+
58
+ record[:status] = :completed
59
+ record[:completed_at] = Time.now.utc
60
+ record
61
+ end
62
+
63
+ def revoke(delegation_id)
64
+ record = @delegations[delegation_id]
65
+ return nil unless record && record[:status] == :active
66
+
67
+ record[:status] = :revoked
68
+ record[:completed_at] = Time.now.utc
69
+ cascade_revoke(delegation_id)
70
+ record
71
+ end
72
+
73
+ def chain(delegation_id)
74
+ result = []
75
+ current = @delegations[delegation_id]
76
+ while current
77
+ result.unshift(current)
78
+ current = current[:parent_delegation_id] ? @delegations[current[:parent_delegation_id]] : nil
79
+ end
80
+ result
81
+ end
82
+
83
+ def for_agent(agent_id, status: nil)
84
+ ids = @agent_delegations[agent_id] || []
85
+ results = ids.filter_map { |id| @delegations[id] }
86
+ results = results.select { |d| d[:status] == status } if status
87
+ results
88
+ end
89
+
90
+ def find(delegation_id)
91
+ @delegations[delegation_id]
92
+ end
93
+
94
+ def stats
95
+ active = @delegations.values.count { |d| d[:status] == :active }
96
+ depths = @delegations.values.map { |d| d[:depth] }
97
+ {
98
+ total: @delegations.size,
99
+ active: active,
100
+ avg_depth: depths.empty? ? 0 : depths.sum.to_f / depths.size,
101
+ max_depth: depths.max || 0
102
+ }
103
+ end
104
+
105
+ private
106
+
107
+ def compute_depth(parent_delegation_id)
108
+ return 0 unless parent_delegation_id
109
+
110
+ parent = find(parent_delegation_id)
111
+ (parent&.fetch(:depth, 0) || 0) + 1
112
+ end
113
+
114
+ def cascade_revoke(parent_id)
115
+ @delegations.each_value do |d|
116
+ next unless d[:parent_delegation_id] == parent_id && d[:status] == :active
117
+
118
+ d[:status] = :revoked
119
+ d[:completed_at] = Time.now.utc
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Helpers
9
+ module PeerVerify
10
+ class << self
11
+ def sign_message(payload, private_key_b64)
12
+ require 'ed25519'
13
+ key = Ed25519::SigningKey.new(Base64.strict_decode64(private_key_b64))
14
+ message_bytes = json_dump(payload)
15
+ signature = key.sign(message_bytes)
16
+ { payload: payload, signature: Base64.strict_encode64(signature), signed_bytes: message_bytes }
17
+ end
18
+
19
+ def verify_message(signed_message, org_id:)
20
+ peer = find_peer(org_id)
21
+ return { valid: false, org_id: org_id, reason: :unknown_peer } unless peer
22
+
23
+ require 'ed25519'
24
+ pub_key_b64 = peer[:public_key].sub(/\Aed25519:/, '')
25
+ verify_key = Ed25519::VerifyKey.new(Base64.strict_decode64(pub_key_b64))
26
+ signature = Base64.strict_decode64(signed_message[:signature])
27
+ message_bytes = signed_message[:signed_bytes] || json_dump(signed_message[:payload])
28
+ verify_key.verify(signature, message_bytes)
29
+ { valid: true, org_id: org_id }
30
+ rescue Ed25519::VerifyError
31
+ { valid: false, org_id: org_id, reason: :invalid_signature }
32
+ rescue StandardError => e
33
+ { valid: false, org_id: org_id, reason: :error, message: e.message }
34
+ end
35
+
36
+ def check_rate_limit(org_id)
37
+ @counters ||= Hash.new { |h, k| h[k] = { count: 0, window_start: Time.now.utc } }
38
+ counter = @counters[org_id]
39
+ peer = find_peer(org_id)
40
+ limit = peer&.dig(:rate_limit) || 100
41
+
42
+ if Time.now.utc - counter[:window_start] > 60
43
+ counter[:count] = 0
44
+ counter[:window_start] = Time.now.utc
45
+ end
46
+
47
+ counter[:count] += 1
48
+ return { allowed: true, remaining: limit - counter[:count] } if counter[:count] <= limit
49
+
50
+ { allowed: false, error: :rate_limited, org_id: org_id }
51
+ end
52
+
53
+ def reset_counters!
54
+ @counters = nil
55
+ end
56
+
57
+ private
58
+
59
+ def find_peer(org_id)
60
+ return nil unless defined?(Legion::Settings)
61
+
62
+ peers = Legion::Settings.dig(:mesh, :trusted_peers) || []
63
+ peers.find { |p| p[:org_id] == org_id }
64
+ end
65
+
66
+ def json_dump(data)
67
+ if defined?(Legion::JSON)
68
+ Legion::JSON.dump({ data: data })
69
+ else
70
+ require 'json'
71
+ ::JSON.dump({ data: data })
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -13,11 +13,14 @@ module Legion
13
13
  @messages = []
14
14
  end
15
15
 
16
- def register_agent(agent_id, capabilities: [], endpoint: nil)
16
+ def register_agent(agent_id, capabilities: [], endpoint: nil, source: :native, node: nil)
17
17
  @agents[agent_id] = {
18
18
  agent_id: agent_id,
19
19
  capabilities: capabilities,
20
20
  endpoint: endpoint,
21
+ source: source,
22
+ node: node || local_node_name,
23
+ generation: 1,
21
24
  registered_at: Time.now.utc,
22
25
  last_seen: Time.now.utc,
23
26
  status: :online
@@ -39,6 +42,7 @@ module Legion
39
42
 
40
43
  agent[:last_seen] = Time.now.utc
41
44
  agent[:status] = :online
45
+ agent[:generation] = (agent[:generation] || 0) + 1
42
46
  end
43
47
 
44
48
  def find_by_capability(capability)
@@ -81,9 +85,21 @@ module Legion
81
85
  @agents.values.select { |a| a[:status] == :online }
82
86
  end
83
87
 
88
+ def all_agents
89
+ @agents.values
90
+ end
91
+
84
92
  def count
85
93
  @agents.size
86
94
  end
95
+
96
+ private
97
+
98
+ def local_node_name
99
+ Legion::Settings[:client][:name]
100
+ rescue StandardError
101
+ 'unknown'
102
+ end
87
103
  end
88
104
  end
89
105
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/delegation'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Runners
9
+ module Delegation
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex)
11
+
12
+ def delegate(from:, to:, task_context:, consent_level: :execute, parent_delegation_id: nil, **) # rubocop:disable Metrics/ParameterLists
13
+ result = delegation_tracker.create(
14
+ from: from,
15
+ to: to,
16
+ task_context: task_context,
17
+ consent_level: consent_level.to_sym,
18
+ parent_delegation_id: parent_delegation_id
19
+ )
20
+ return { success: false, **result } if result[:error]
21
+
22
+ publish_delegation_event('delegation.created', result)
23
+ { success: true, delegation_id: result[:delegation_id], depth: result[:depth] }
24
+ end
25
+
26
+ def complete_delegation(delegation_id:, **)
27
+ result = delegation_tracker.complete(delegation_id)
28
+ return { success: false, reason: :not_found_or_inactive } unless result
29
+
30
+ publish_delegation_event('delegation.completed', result)
31
+ { success: true, delegation_id: delegation_id }
32
+ end
33
+
34
+ def revoke_delegation(delegation_id:, **)
35
+ result = delegation_tracker.revoke(delegation_id)
36
+ return { success: false, reason: :not_found_or_inactive } unless result
37
+
38
+ publish_delegation_event('delegation.revoked', result)
39
+ { success: true, delegation_id: delegation_id }
40
+ end
41
+
42
+ def delegation_chain(delegation_id:, **)
43
+ chain = delegation_tracker.chain(delegation_id)
44
+ { success: true, chain: chain, depth: [chain.size - 1, 0].max }
45
+ end
46
+
47
+ def agent_delegations(agent_id:, status: nil, **)
48
+ results = delegation_tracker.for_agent(agent_id, status: status&.to_sym)
49
+ { success: true, delegations: results, count: results.size }
50
+ end
51
+
52
+ def delegation_stats(**)
53
+ delegation_tracker.stats.merge(success: true)
54
+ end
55
+
56
+ private
57
+
58
+ def publish_delegation_event(event_type, record)
59
+ return unless defined?(Legion::Extensions::Audit::Transport::Messages::Audit)
60
+
61
+ Legion::Extensions::Audit::Transport::Messages::Audit.new(
62
+ event_type: event_type,
63
+ principal_id: record[:from_agent_id],
64
+ action: event_type.split('.').last,
65
+ resource: "delegation:#{record[:delegation_id]}",
66
+ detail: { to: record[:to_agent_id], depth: record[:depth], consent: record[:consent_level] }
67
+ ).publish
68
+ rescue StandardError => e
69
+ Legion::Logging.warn "[mesh] failed to publish #{event_type}: #{e.message}" if defined?(Legion::Logging)
70
+ end
71
+
72
+ def delegation_tracker
73
+ @delegation_tracker ||= begin
74
+ max_depth = nil
75
+ max_active = nil
76
+ if defined?(Legion::Settings)
77
+ max_depth = Legion::Settings.dig(:mesh, :delegation, :max_depth)
78
+ max_active = Legion::Settings.dig(:mesh, :delegation, :max_active_per_agent)
79
+ end
80
+ Helpers::Delegation.new(max_depth: max_depth || 3, max_active_per_agent: max_active || 10)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -64,6 +64,50 @@ module Legion
64
64
  { success: true, expired: expired, count: expired.size }
65
65
  end
66
66
 
67
+ def publish_gossip(**)
68
+ registry = mesh_registry
69
+ peers = registry.all_agents.first(gossip_max_peers).map do |agent|
70
+ agent.slice(:agent_id, :capabilities, :node, :source, :status, :generation,
71
+ :last_seen, :registered_at).transform_values { |v| v.is_a?(Time) ? v.to_s : v }
72
+ end
73
+
74
+ @gossip_round = (@gossip_round || 0) + 1
75
+ publish_gossip_message(peers)
76
+ { success: true, peers_broadcast: peers.size, gossip_round: @gossip_round }
77
+ rescue StandardError => e
78
+ { success: false, reason: :error, message: e.message }
79
+ end
80
+
81
+ def merge_gossip(incoming_peers:, sender: nil, **) # rubocop:disable Lint/UnusedMethodArgument
82
+ registry = mesh_registry
83
+ merged = 0
84
+
85
+ incoming_peers.each do |peer|
86
+ peer = peer.transform_keys(&:to_sym)
87
+ next if peer[:node] == local_node_name
88
+
89
+ local = registry.agents[peer[:agent_id]]
90
+ if local.nil?
91
+ registry.register_agent(
92
+ peer[:agent_id],
93
+ capabilities: (peer[:capabilities] || []).map(&:to_sym),
94
+ source: (peer[:source] || :native).to_sym,
95
+ node: peer[:node]
96
+ )
97
+ registry.agents[peer[:agent_id]][:generation] = peer[:generation] || 1
98
+ merged += 1
99
+ elsif (peer[:generation] || 0) > (local[:generation] || 0)
100
+ local.merge!(peer.slice(:capabilities, :status, :generation, :last_seen))
101
+ local[:capabilities] = (local[:capabilities] || []).map(&:to_sym)
102
+ merged += 1
103
+ end
104
+ end
105
+
106
+ { success: true, merged: merged, total_peers: incoming_peers.size }
107
+ rescue StandardError => e
108
+ { success: false, reason: :error, message: e.message }
109
+ end
110
+
67
111
  private
68
112
 
69
113
  def publish_mesh_departure(agent_id:, capabilities:)
@@ -77,6 +121,29 @@ module Legion
77
121
  Legion::Logging.warn "[mesh] failed to publish departure signal: #{e.message}"
78
122
  end
79
123
 
124
+ def publish_gossip_message(peers)
125
+ return unless defined?(Legion::Extensions::Mesh::Transport::Messages::Gossip)
126
+
127
+ Legion::Extensions::Mesh::Transport::Messages::Gossip.new(
128
+ sender: local_node_name,
129
+ gossip_round: @gossip_round,
130
+ peers: peers
131
+ ).publish
132
+ end
133
+
134
+ def gossip_max_peers
135
+ settings = Legion::Settings.dig(:mesh, :gossip)
136
+ (settings.is_a?(Hash) ? settings[:max_peers_per_message] : nil) || 100
137
+ rescue StandardError
138
+ 100
139
+ end
140
+
141
+ def local_node_name
142
+ Legion::Settings[:client][:name]
143
+ rescue StandardError
144
+ 'unknown'
145
+ end
146
+
80
147
  def mesh_registry
81
148
  @mesh_registry ||= Helpers::Registry.new
82
149
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Messages
8
+ class Gossip < Legion::Transport::Message
9
+ def exchange
10
+ Legion::Transport::Exchanges::Node
11
+ end
12
+
13
+ def routing_key
14
+ 'mesh.gossip'
15
+ end
16
+
17
+ def message
18
+ {
19
+ type: 'mesh_gossip',
20
+ sender: @options[:sender],
21
+ gossip_round: @options[:gossip_round] || 0,
22
+ peers: @options[:peers] || []
23
+ }
24
+ end
25
+
26
+ def type
27
+ 'mesh_gossip'
28
+ end
29
+
30
+ def encrypt?
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Queues
8
+ class Gossip < Legion::Transport::Queue
9
+ def queue_name
10
+ 'mesh.gossip'
11
+ end
12
+
13
+ def exchange
14
+ Legion::Transport::Exchanges::Node
15
+ end
16
+
17
+ def routing_key
18
+ 'mesh.gossip'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Mesh
6
- VERSION = '0.2.3'
6
+ VERSION = '0.2.5'
7
7
  end
8
8
  end
9
9
  end
@@ -5,8 +5,11 @@ require 'legion/extensions/mesh/helpers/topology'
5
5
  require 'legion/extensions/mesh/helpers/registry'
6
6
  require 'legion/extensions/mesh/helpers/preference_profile'
7
7
  require 'legion/extensions/mesh/helpers/pending_requests'
8
+ require 'legion/extensions/mesh/helpers/delegation'
9
+ require 'legion/extensions/mesh/helpers/peer_verify'
8
10
  require 'legion/extensions/mesh/runners/mesh'
9
11
  require 'legion/extensions/mesh/runners/preferences'
12
+ require 'legion/extensions/mesh/runners/delegation'
10
13
 
11
14
  if defined?(Legion::Transport)
12
15
  require 'legion/extensions/mesh/transport/messages/preference_query'
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework actor base class since legionio gem is not available in test
4
+ unless defined?(Legion::Extensions::Actors::Every)
5
+ module Legion
6
+ module Extensions
7
+ module Actors
8
+ class Every # rubocop:disable Lint/EmptyClass
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ $LOADED_FEATURES << 'legion/extensions/actors/every' unless $LOADED_FEATURES.include?('legion/extensions/actors/every')
16
+
17
+ require 'legion/extensions/mesh/actors/gossip'
18
+
19
+ RSpec.describe Legion::Extensions::Mesh::Actor::Gossip do
20
+ it 'fires every 15 seconds' do
21
+ expect(described_class.new.time).to eq(15)
22
+ end
23
+
24
+ it 'calls publish_gossip runner function' do
25
+ instance = described_class.allocate
26
+ expect(instance.runner_function).to eq(:publish_gossip)
27
+ end
28
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Mesh::Helpers::Delegation do
6
+ subject(:tracker) { described_class.new(max_depth: 3, max_active_per_agent: 5) }
7
+
8
+ describe '#create' do
9
+ it 'creates a delegation record' do
10
+ result = tracker.create(from: 'agent-a', to: 'agent-b', task_context: 'task-1', consent_level: :execute)
11
+ expect(result[:delegation_id]).to start_with('del-')
12
+ expect(result[:from_agent_id]).to eq('agent-a')
13
+ expect(result[:to_agent_id]).to eq('agent-b')
14
+ expect(result[:status]).to eq(:active)
15
+ expect(result[:depth]).to eq(0)
16
+ end
17
+
18
+ it 'increments depth for chained delegations' do
19
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
20
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
21
+ parent_delegation_id: d1[:delegation_id])
22
+ expect(d2[:depth]).to eq(1)
23
+ end
24
+
25
+ it 'refuses delegation beyond max depth' do
26
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
27
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
28
+ parent_delegation_id: d1[:delegation_id])
29
+ d3 = tracker.create(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
30
+ parent_delegation_id: d2[:delegation_id])
31
+ d4 = tracker.create(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
32
+ parent_delegation_id: d3[:delegation_id])
33
+ expect(d4[:error]).to eq(:max_depth_exceeded)
34
+ end
35
+
36
+ it 'refuses consent escalation' do
37
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
38
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :admin,
39
+ parent_delegation_id: d1[:delegation_id])
40
+ expect(d2[:error]).to eq(:consent_escalation)
41
+ end
42
+
43
+ it 'allows consent de-escalation' do
44
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :admin)
45
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :read,
46
+ parent_delegation_id: d1[:delegation_id])
47
+ expect(d2[:delegation_id]).to start_with('del-')
48
+ end
49
+
50
+ it 'refuses when max active per agent exceeded' do
51
+ 5.times { |i| tracker.create(from: 'a', to: "b#{i}", task_context: 't', consent_level: :execute) }
52
+ result = tracker.create(from: 'a', to: 'b6', task_context: 't', consent_level: :execute)
53
+ expect(result[:error]).to eq(:max_active_exceeded)
54
+ end
55
+ end
56
+
57
+ describe '#complete' do
58
+ it 'marks a delegation as completed' do
59
+ d = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
60
+ result = tracker.complete(d[:delegation_id])
61
+ expect(result[:status]).to eq(:completed)
62
+ expect(result[:completed_at]).to be_a(Time)
63
+ end
64
+
65
+ it 'returns nil for non-existent delegation' do
66
+ expect(tracker.complete('del-nonexistent')).to be_nil
67
+ end
68
+ end
69
+
70
+ describe '#revoke' do
71
+ it 'marks delegation and children as revoked' do
72
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
73
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
74
+ parent_delegation_id: d1[:delegation_id])
75
+ tracker.revoke(d1[:delegation_id])
76
+
77
+ expect(tracker.find(d1[:delegation_id])[:status]).to eq(:revoked)
78
+ expect(tracker.find(d2[:delegation_id])[:status]).to eq(:revoked)
79
+ end
80
+ end
81
+
82
+ describe '#chain' do
83
+ it 'returns the full delegation chain' do
84
+ d1 = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
85
+ d2 = tracker.create(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
86
+ parent_delegation_id: d1[:delegation_id])
87
+ chain = tracker.chain(d2[:delegation_id])
88
+ expect(chain.size).to eq(2)
89
+ expect(chain.first[:from_agent_id]).to eq('a')
90
+ expect(chain.last[:from_agent_id]).to eq('b')
91
+ end
92
+ end
93
+
94
+ describe '#for_agent' do
95
+ it 'returns delegations involving an agent' do
96
+ tracker.create(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
97
+ tracker.create(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
98
+ tracker.create(from: 'x', to: 'y', task_context: 't3', consent_level: :execute)
99
+
100
+ results = tracker.for_agent('a')
101
+ expect(results.size).to eq(2)
102
+ end
103
+
104
+ it 'filters by status' do
105
+ d = tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
106
+ tracker.create(from: 'a', to: 'c', task_context: 't', consent_level: :execute)
107
+ tracker.complete(d[:delegation_id])
108
+
109
+ active = tracker.for_agent('a', status: :active)
110
+ expect(active.size).to eq(1)
111
+ end
112
+ end
113
+
114
+ describe '#stats' do
115
+ it 'returns summary statistics' do
116
+ tracker.create(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
117
+ d = tracker.create(from: 'a', to: 'c', task_context: 't', consent_level: :execute)
118
+ tracker.complete(d[:delegation_id])
119
+
120
+ stats = tracker.stats
121
+ expect(stats[:total]).to eq(2)
122
+ expect(stats[:active]).to eq(1)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ed25519'
5
+
6
+ RSpec.describe Legion::Extensions::Mesh::Helpers::PeerVerify do
7
+ let(:signing_key) { Ed25519::SigningKey.generate }
8
+ let(:verify_key) { signing_key.verify_key }
9
+ let(:private_key_b64) { Base64.strict_encode64(signing_key.to_bytes) }
10
+ let(:public_key_b64) { Base64.strict_encode64(verify_key.to_bytes) }
11
+
12
+ let(:peer_config) do
13
+ [{ org_id: 'acme', public_key: "ed25519:#{public_key_b64}",
14
+ capabilities: %w[query task], rate_limit: 5 }]
15
+ end
16
+
17
+ before do
18
+ described_class.reset_counters!
19
+ stub_const('Legion::Settings', Module.new) unless defined?(Legion::Settings)
20
+ allow(Legion::Settings).to receive(:dig).with(:mesh, :trusted_peers).and_return(peer_config)
21
+ end
22
+
23
+ describe '.sign_message' do
24
+ it 'signs a payload with Ed25519' do
25
+ result = described_class.sign_message({ hello: 'world' }, private_key_b64)
26
+ expect(result[:signature]).to be_a(String)
27
+ expect(result[:payload]).to eq({ hello: 'world' })
28
+ end
29
+ end
30
+
31
+ describe '.verify_message' do
32
+ it 'verifies a validly signed message' do
33
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
34
+ result = described_class.verify_message(signed, org_id: 'acme')
35
+ expect(result[:valid]).to be true
36
+ expect(result[:org_id]).to eq('acme')
37
+ end
38
+
39
+ it 'rejects a tampered message' do
40
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
41
+ signed[:signed_bytes] = 'tampered data'
42
+ result = described_class.verify_message(signed, org_id: 'acme')
43
+ expect(result[:valid]).to be false
44
+ expect(result[:reason]).to eq(:invalid_signature)
45
+ end
46
+
47
+ it 'rejects unknown peers' do
48
+ signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
49
+ result = described_class.verify_message(signed, org_id: 'unknown-org')
50
+ expect(result[:valid]).to be false
51
+ expect(result[:reason]).to eq(:unknown_peer)
52
+ end
53
+ end
54
+
55
+ describe '.check_rate_limit' do
56
+ it 'allows messages within limit' do
57
+ result = described_class.check_rate_limit('acme')
58
+ expect(result[:allowed]).to be true
59
+ end
60
+
61
+ it 'blocks messages over limit' do
62
+ 5.times { described_class.check_rate_limit('acme') }
63
+ result = described_class.check_rate_limit('acme')
64
+ expect(result[:allowed]).to be false
65
+ expect(result[:error]).to eq(:rate_limited)
66
+ end
67
+
68
+ it 'resets after window expires' do
69
+ 5.times { described_class.check_rate_limit('acme') }
70
+ counters = described_class.instance_variable_get(:@counters)
71
+ counters['acme'][:window_start] = Time.now.utc - 61
72
+ result = described_class.check_rate_limit('acme')
73
+ expect(result[:allowed]).to be true
74
+ end
75
+ end
76
+ end
@@ -313,6 +313,32 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::Registry do
313
313
  end
314
314
  end
315
315
 
316
+ describe 'gossip fields' do
317
+ it 'stores source, node, and generation on registration' do
318
+ registry.register_agent('agent-1', capabilities: [:test], source: :native, node: 'node-01')
319
+ agent = registry.agents['agent-1']
320
+ expect(agent[:source]).to eq(:native)
321
+ expect(agent[:node]).to eq('node-01')
322
+ expect(agent[:generation]).to eq(1)
323
+ end
324
+
325
+ it 'increments generation on heartbeat' do
326
+ registry.register_agent('agent-1', capabilities: [:test])
327
+ registry.heartbeat('agent-1')
328
+ expect(registry.agents['agent-1'][:generation]).to eq(2)
329
+ end
330
+ end
331
+
332
+ describe '#all_agents' do
333
+ it 'returns all agent records as an array' do
334
+ registry.register_agent('a1', capabilities: [:search])
335
+ registry.register_agent('a2', capabilities: [:compute])
336
+ all = registry.all_agents
337
+ expect(all).to be_an(Array)
338
+ expect(all.size).to eq(2)
339
+ end
340
+ end
341
+
316
342
  describe '#count' do
317
343
  it 'returns 0 for an empty registry' do
318
344
  expect(registry.count).to eq(0)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Mesh::Runners::Delegation do
6
+ subject { Object.new.extend(described_class) }
7
+
8
+ before do
9
+ subject.instance_variable_set(:@delegation_tracker, nil)
10
+ end
11
+
12
+ describe '#delegate' do
13
+ it 'creates a delegation' do
14
+ result = subject.delegate(from: 'a', to: 'b', task_context: 'task-1', consent_level: :execute)
15
+ expect(result[:success]).to be true
16
+ expect(result[:delegation_id]).to start_with('del-')
17
+ expect(result[:depth]).to eq(0)
18
+ end
19
+
20
+ it 'returns failure for depth exceeded' do
21
+ d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
22
+ d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
23
+ parent_delegation_id: d1[:delegation_id])
24
+ d3 = subject.delegate(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
25
+ parent_delegation_id: d2[:delegation_id])
26
+ d4 = subject.delegate(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
27
+ parent_delegation_id: d3[:delegation_id])
28
+ expect(d4[:success]).to be false
29
+ end
30
+ end
31
+
32
+ describe '#complete_delegation' do
33
+ it 'completes an active delegation' do
34
+ d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
35
+ result = subject.complete_delegation(delegation_id: d[:delegation_id])
36
+ expect(result[:success]).to be true
37
+ end
38
+
39
+ it 'fails for non-existent delegation' do
40
+ result = subject.complete_delegation(delegation_id: 'del-fake')
41
+ expect(result[:success]).to be false
42
+ end
43
+ end
44
+
45
+ describe '#revoke_delegation' do
46
+ it 'revokes an active delegation' do
47
+ d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
48
+ result = subject.revoke_delegation(delegation_id: d[:delegation_id])
49
+ expect(result[:success]).to be true
50
+ end
51
+ end
52
+
53
+ describe '#delegation_chain' do
54
+ it 'returns the full chain' do
55
+ d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
56
+ d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
57
+ parent_delegation_id: d1[:delegation_id])
58
+ result = subject.delegation_chain(delegation_id: d2[:delegation_id])
59
+ expect(result[:success]).to be true
60
+ expect(result[:chain].size).to eq(2)
61
+ expect(result[:depth]).to eq(1)
62
+ end
63
+ end
64
+
65
+ describe '#agent_delegations' do
66
+ it 'lists delegations for an agent' do
67
+ subject.delegate(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
68
+ subject.delegate(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
69
+ result = subject.agent_delegations(agent_id: 'a')
70
+ expect(result[:success]).to be true
71
+ expect(result[:count]).to eq(2)
72
+ end
73
+ end
74
+
75
+ describe '#delegation_stats' do
76
+ it 'returns statistics' do
77
+ subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
78
+ result = subject.delegation_stats
79
+ expect(result[:success]).to be true
80
+ expect(result[:total]).to eq(1)
81
+ expect(result[:active]).to eq(1)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Gossip runner methods' do
6
+ subject { Object.new.extend(Legion::Extensions::Mesh::Runners::Mesh) }
7
+
8
+ before do
9
+ allow(subject).to receive(:mesh_registry).and_return(
10
+ Legion::Extensions::Mesh::Helpers::Registry.new
11
+ )
12
+ end
13
+
14
+ describe '#merge_gossip' do
15
+ it 'adds unknown agents from incoming gossip' do
16
+ incoming = [
17
+ { agent_id: 'remote-1', capabilities: [:test], node: 'node-02',
18
+ source: :native, status: :online, generation: 5,
19
+ last_seen: Time.now.utc.to_s, registered_at: Time.now.utc.to_s }
20
+ ]
21
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-02')
22
+ expect(result[:success]).to be true
23
+ expect(result[:merged]).to eq(1)
24
+ end
25
+
26
+ it 'updates agents with higher generation' do
27
+ registry = subject.send(:mesh_registry)
28
+ registry.register_agent('agent-1', capabilities: [:old], node: 'node-01')
29
+
30
+ incoming = [
31
+ { agent_id: 'agent-1', capabilities: [:updated], node: 'node-01',
32
+ source: :native, status: :online, generation: 99,
33
+ last_seen: Time.now.utc.to_s, registered_at: Time.now.utc.to_s }
34
+ ]
35
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-01')
36
+ expect(result[:merged]).to eq(1)
37
+ expect(registry.agents['agent-1'][:generation]).to eq(99)
38
+ end
39
+
40
+ it 'skips agents from local node' do
41
+ local_name = 'local-node'
42
+ allow(subject).to receive(:local_node_name).and_return(local_name)
43
+
44
+ incoming = [
45
+ { agent_id: 'local-agent', node: local_name, generation: 1 }
46
+ ]
47
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'other-node')
48
+ expect(result[:merged]).to eq(0)
49
+ end
50
+
51
+ it 'ignores agents with lower or equal generation' do
52
+ registry = subject.send(:mesh_registry)
53
+ registry.register_agent('agent-1', capabilities: [:test], node: 'node-01')
54
+ # generation is 1 after register
55
+
56
+ incoming = [
57
+ { agent_id: 'agent-1', capabilities: [:test], node: 'node-01',
58
+ generation: 1, source: :native, status: :online }
59
+ ]
60
+ result = subject.merge_gossip(incoming_peers: incoming, sender: 'node-02')
61
+ expect(result[:merged]).to eq(0)
62
+ end
63
+ end
64
+
65
+ describe '#publish_gossip' do
66
+ it 'returns success with peer count' do
67
+ allow(subject).to receive(:publish_gossip_message)
68
+ result = subject.publish_gossip
69
+ expect(result[:success]).to be true
70
+ expect(result[:peers_broadcast]).to be_a(Integer)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ unless defined?(Legion::Transport::Message)
6
+ module Legion
7
+ module Transport
8
+ class Message
9
+ def initialize(**options)
10
+ @options = options
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ unless defined?(Legion::Transport::Exchanges::Node)
18
+ module Legion
19
+ module Transport
20
+ module Exchanges
21
+ class Node # rubocop:disable Lint/EmptyClass
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ require 'legion/extensions/mesh/transport/messages/gossip'
29
+
30
+ RSpec.describe Legion::Extensions::Mesh::Transport::Messages::Gossip do
31
+ subject do
32
+ described_class.new(
33
+ sender: 'node-01',
34
+ gossip_round: 5,
35
+ peers: [{ agent_id: 'w1', capabilities: [:test], node: 'node-01', generation: 3 }]
36
+ )
37
+ end
38
+
39
+ it 'uses the node exchange' do
40
+ expect(subject.exchange).to eq(Legion::Transport::Exchanges::Node)
41
+ end
42
+
43
+ it 'routes to mesh.gossip' do
44
+ expect(subject.routing_key).to eq('mesh.gossip')
45
+ end
46
+
47
+ it 'includes sender and peers in the message body' do
48
+ msg = subject.message
49
+ expect(msg[:type]).to eq('mesh_gossip')
50
+ expect(msg[:sender]).to eq('node-01')
51
+ expect(msg[:peers]).to be_an(Array)
52
+ expect(msg[:gossip_round]).to eq(5)
53
+ end
54
+ end
metadata CHANGED
@@ -1,14 +1,42 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-mesh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ed25519
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
12
40
  description: Agent-to-agent mesh communication protocol for brain-modeled agentic
13
41
  AI
14
42
  email:
@@ -20,33 +48,45 @@ files:
20
48
  - Gemfile
21
49
  - lex-mesh.gemspec
22
50
  - lib/legion/extensions/mesh.rb
51
+ - lib/legion/extensions/mesh/actors/gossip.rb
23
52
  - lib/legion/extensions/mesh/actors/heartbeat.rb
24
53
  - lib/legion/extensions/mesh/actors/pending_expiry.rb
25
54
  - lib/legion/extensions/mesh/actors/preference_listener.rb
26
55
  - lib/legion/extensions/mesh/actors/silence_watchdog.rb
27
56
  - lib/legion/extensions/mesh/client.rb
57
+ - lib/legion/extensions/mesh/helpers/delegation.rb
58
+ - lib/legion/extensions/mesh/helpers/peer_verify.rb
28
59
  - lib/legion/extensions/mesh/helpers/pending_requests.rb
29
60
  - lib/legion/extensions/mesh/helpers/preference_profile.rb
30
61
  - lib/legion/extensions/mesh/helpers/registry.rb
31
62
  - lib/legion/extensions/mesh/helpers/topology.rb
63
+ - lib/legion/extensions/mesh/runners/delegation.rb
32
64
  - lib/legion/extensions/mesh/runners/mesh.rb
33
65
  - lib/legion/extensions/mesh/runners/preferences.rb
66
+ - lib/legion/extensions/mesh/transport/messages/gossip.rb
34
67
  - lib/legion/extensions/mesh/transport/messages/mesh_departure.rb
35
68
  - lib/legion/extensions/mesh/transport/messages/preference_query.rb
36
69
  - lib/legion/extensions/mesh/transport/messages/preference_response.rb
70
+ - lib/legion/extensions/mesh/transport/queues/gossip.rb
37
71
  - lib/legion/extensions/mesh/transport/queues/preference.rb
38
72
  - lib/legion/extensions/mesh/version.rb
73
+ - spec/legion/extensions/mesh/actors/gossip_spec.rb
39
74
  - spec/legion/extensions/mesh/actors/heartbeat_spec.rb
40
75
  - spec/legion/extensions/mesh/actors/pending_expiry_spec.rb
41
76
  - spec/legion/extensions/mesh/actors/preference_listener_spec.rb
42
77
  - spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb
43
78
  - spec/legion/extensions/mesh/client_spec.rb
79
+ - spec/legion/extensions/mesh/helpers/delegation_spec.rb
80
+ - spec/legion/extensions/mesh/helpers/peer_verify_spec.rb
44
81
  - spec/legion/extensions/mesh/helpers/pending_requests_spec.rb
45
82
  - spec/legion/extensions/mesh/helpers/preference_profile_spec.rb
46
83
  - spec/legion/extensions/mesh/helpers/registry_spec.rb
47
84
  - spec/legion/extensions/mesh/helpers/topology_spec.rb
85
+ - spec/legion/extensions/mesh/runners/delegation_spec.rb
86
+ - spec/legion/extensions/mesh/runners/mesh_gossip_spec.rb
48
87
  - spec/legion/extensions/mesh/runners/mesh_spec.rb
49
88
  - spec/legion/extensions/mesh/runners/preferences_spec.rb
89
+ - spec/legion/extensions/mesh/transport/messages/gossip_spec.rb
50
90
  - spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb
51
91
  - spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb
52
92
  - spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb