legionio 1.4.107 → 1.4.109
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 +4 -4
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +1 -1
- data/lib/legion/cli/doctor.rb +3 -0
- data/lib/legion/cli/marketplace_command.rb +81 -0
- data/lib/legion/cluster/leader.rb +58 -0
- data/lib/legion/cluster/lock.rb +159 -0
- data/lib/legion/cluster.rb +8 -0
- data/lib/legion/extensions.rb +34 -0
- data/lib/legion/process_role.rb +45 -0
- data/lib/legion/registry/governance.rb +51 -0
- data/lib/legion/registry/persistence.rb +74 -0
- data/lib/legion/registry/security_scanner.rb +34 -3
- data/lib/legion/registry.rb +12 -0
- data/lib/legion/service.rb +15 -2
- data/lib/legion/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52e1d5b8749747a9fe4b89302f1e0122f86a7630e02f668dfa5df8de363399d7
|
|
4
|
+
data.tar.gz: 315b433465e06726391616a01d0cd6d8d8b13c849eff241a2702cdb8ab66cbaa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b8c67b2e70b786fc16abb5f43ce8a4d8155e87b22fcf4e901afb24c021fc2112b07b884b75e16f9a48e046a959cacc035885d4251f4812e3eb805f2465e66f6
|
|
7
|
+
data.tar.gz: 79b46b3605b2c249090b11dd4d12db71ff523061b9f9ae929cca2d7a1b3646489c3afb01f8dd36d6dcc532d925c86b2b48c6b90ee9cbac39c5bbdee5514618c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.109] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Legion::Cluster::Lock` Redis backend: SETNX + TTL acquire, Lua compare-and-delete release, thread-safe token storage via `Concurrent::Map`
|
|
7
|
+
- `Legion::Cluster::Lock.backend` auto-detection: `:redis` (preferred), `:postgres` (advisory locks), or `:none`
|
|
8
|
+
- `Legion::ProcessRole` module: role presets (full, api, worker, router) controlling which Service subsystems start
|
|
9
|
+
- `Legion::Service#initialize` role integration: `role:` parameter resolves via `ProcessRole`, explicit kwargs override role defaults
|
|
10
|
+
- 24 new specs (2404 total, 0 failures)
|
|
11
|
+
|
|
12
|
+
## [1.4.108] - 2026-03-21
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `Legion::Registry::SecurityScanner` static analysis check — detects dangerous Ruby patterns (eval, system, exec, backtick, IO.popen, Open3) in extension source files
|
|
16
|
+
- `Legion::Registry::Persistence` module — syncs in-memory registry with `extensions_registry` DB table (load at boot, persist on register/update)
|
|
17
|
+
- Boot-time auto-population of `Legion::Registry` from discovered extensions with gemspec capability reading
|
|
18
|
+
- `Legion::Sandbox` auto-wiring from gemspec `legion.capabilities` metadata at extension load
|
|
19
|
+
- `legion marketplace install NAME` command — validates lex- naming, installs gem, registers in registry
|
|
20
|
+
- `legion marketplace publish` command — full pipeline: rspec, rubocop, gem build, gem push, security scan, register
|
|
21
|
+
- `Legion::Registry::Governance` module — naming convention enforcement, auto-approve by risk tier, review requirements via `Legion::Settings`
|
|
22
|
+
- 65 new specs (2380 total, 0 failures)
|
|
23
|
+
|
|
3
24
|
## [1.4.107] - 2026-03-21
|
|
4
25
|
|
|
5
26
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
|
|
|
9
9
|
|
|
10
10
|
**GitHub**: https://github.com/LegionIO/LegionIO
|
|
11
11
|
**Gem**: `legionio`
|
|
12
|
-
**Version**: 1.4.
|
|
12
|
+
**Version**: 1.4.107
|
|
13
13
|
**License**: Apache-2.0
|
|
14
14
|
**Docker**: `legionio/legion`
|
|
15
15
|
**Ruby**: >= 3.4
|
|
@@ -231,6 +231,87 @@ module Legion
|
|
|
231
231
|
out.error(e.message)
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
+
# ──────────────────────────────────────────────────────────
|
|
235
|
+
# install
|
|
236
|
+
# ──────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
desc 'install NAME', 'Install a lex extension gem'
|
|
239
|
+
def install(name)
|
|
240
|
+
require 'legion/registry'
|
|
241
|
+
out = formatter
|
|
242
|
+
|
|
243
|
+
unless name.start_with?('lex-')
|
|
244
|
+
out.error("Extension name must start with 'lex-'")
|
|
245
|
+
return
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if Kernel.system('gem', 'install', name)
|
|
249
|
+
entry = Legion::Registry::Entry.new(name: name, status: :active, airb_status: 'pending')
|
|
250
|
+
Legion::Registry.register(entry)
|
|
251
|
+
out.success("'#{name}' installed successfully")
|
|
252
|
+
else
|
|
253
|
+
out.error("Failed to install '#{name}'")
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ──────────────────────────────────────────────────────────
|
|
258
|
+
# publish
|
|
259
|
+
# ──────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
desc 'publish', 'Publish current extension to rubygems'
|
|
262
|
+
def publish
|
|
263
|
+
require 'legion/registry'
|
|
264
|
+
require 'legion/registry/security_scanner'
|
|
265
|
+
out = formatter
|
|
266
|
+
|
|
267
|
+
gemspec_files = Dir.glob('*.gemspec')
|
|
268
|
+
if gemspec_files.empty?
|
|
269
|
+
out.error('No gemspec found — publish aborted')
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
gemspec_path = gemspec_files.first
|
|
274
|
+
gem_name = File.basename(gemspec_path, '.gemspec')
|
|
275
|
+
|
|
276
|
+
unless Kernel.system('bundle', 'exec', 'rspec')
|
|
277
|
+
out.error('Specs failed — publish aborted')
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
unless Kernel.system('bundle', 'exec', 'rubocop')
|
|
282
|
+
out.error('Rubocop failed — publish aborted')
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
unless Kernel.system('gem', 'build', gemspec_path)
|
|
287
|
+
out.error("Failed to build gem '#{gem_name}'")
|
|
288
|
+
return
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
gem_files = Dir.glob("#{gem_name}-*.gem")
|
|
292
|
+
if gem_files.empty?
|
|
293
|
+
out.error('No built gem file found after build')
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
gem_file = gem_files.max_by { |f| File.mtime(f) }
|
|
298
|
+
|
|
299
|
+
unless Kernel.system('gem', 'push', gem_file)
|
|
300
|
+
out.error("Failed to push '#{gem_file}'")
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
scanner = Legion::Registry::SecurityScanner.new
|
|
305
|
+
scan_result = scanner.scan(name: gem_file)
|
|
306
|
+
|
|
307
|
+
version = gem_file.sub("#{gem_name}-", '').sub('.gem', '')
|
|
308
|
+
entry = Legion::Registry::Entry.new(name: gem_name, version: version,
|
|
309
|
+
status: :active, airb_status: 'pending')
|
|
310
|
+
Legion::Registry.register(entry)
|
|
311
|
+
|
|
312
|
+
out.success("'#{gem_name}' v#{version} published — security: #{scan_result[:passed] ? 'passed' : 'failed'}")
|
|
313
|
+
end
|
|
314
|
+
|
|
234
315
|
# ──────────────────────────────────────────────────────────
|
|
235
316
|
# stats
|
|
236
317
|
# ──────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Cluster
|
|
5
|
+
class Leader
|
|
6
|
+
HEARTBEAT_INTERVAL = 10 # seconds
|
|
7
|
+
LOCK_NAME = 'legion_leader'
|
|
8
|
+
|
|
9
|
+
attr_reader :node_id, :is_leader
|
|
10
|
+
|
|
11
|
+
def initialize(node_id: SecureRandom.uuid)
|
|
12
|
+
@node_id = node_id
|
|
13
|
+
@is_leader = false
|
|
14
|
+
@heartbeat_thread = nil
|
|
15
|
+
@running = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start
|
|
19
|
+
@running = true
|
|
20
|
+
@heartbeat_thread = Thread.new { election_loop }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stop
|
|
24
|
+
@running = false
|
|
25
|
+
@heartbeat_thread&.join(HEARTBEAT_INTERVAL + 2)
|
|
26
|
+
resign if @is_leader
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def leader?
|
|
30
|
+
@is_leader
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def election_loop
|
|
36
|
+
while @running
|
|
37
|
+
attempt_election
|
|
38
|
+
sleep(HEARTBEAT_INTERVAL)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def attempt_election
|
|
43
|
+
@is_leader = if Lock.acquire(name: LOCK_NAME)
|
|
44
|
+
true
|
|
45
|
+
else
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError
|
|
49
|
+
@is_leader = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resign
|
|
53
|
+
Lock.release(name: LOCK_NAME) if @is_leader
|
|
54
|
+
@is_leader = false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Cluster
|
|
7
|
+
module Lock
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
@tokens = if defined?(Concurrent::Map)
|
|
11
|
+
Concurrent::Map.new
|
|
12
|
+
else
|
|
13
|
+
{}
|
|
14
|
+
end
|
|
15
|
+
@tokens_mutex = Mutex.new unless defined?(Concurrent::Map)
|
|
16
|
+
|
|
17
|
+
def tokens
|
|
18
|
+
@tokens
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def backend
|
|
22
|
+
if defined?(Legion::Cache) &&
|
|
23
|
+
Legion::Cache.respond_to?(:const_defined?) &&
|
|
24
|
+
Legion::Cache.const_defined?(:Redis, false) &&
|
|
25
|
+
Legion::Cache::Redis.respond_to?(:client) &&
|
|
26
|
+
!Legion::Cache::Redis.client.nil?
|
|
27
|
+
:redis
|
|
28
|
+
elsif defined?(Legion::Data) &&
|
|
29
|
+
Legion::Data.respond_to?(:connection) &&
|
|
30
|
+
!Legion::Data.connection.nil?
|
|
31
|
+
:postgres
|
|
32
|
+
else
|
|
33
|
+
:none
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def acquire(name:, ttl: 30, timeout: 5) # rubocop:disable Lint/UnusedMethodArgument
|
|
38
|
+
case backend
|
|
39
|
+
when :redis
|
|
40
|
+
acquire_redis(name: name, ttl: ttl)
|
|
41
|
+
when :postgres
|
|
42
|
+
acquire_postgres(name: name)
|
|
43
|
+
else
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def release(name:, token: nil)
|
|
49
|
+
case backend
|
|
50
|
+
when :redis
|
|
51
|
+
release_redis(name: name, token: token)
|
|
52
|
+
when :postgres
|
|
53
|
+
release_postgres(name: name)
|
|
54
|
+
else
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def with_lock(name:, ttl: 30, timeout: 5)
|
|
60
|
+
acquired = acquire(name: name, ttl: ttl, timeout: timeout)
|
|
61
|
+
return unless acquired
|
|
62
|
+
|
|
63
|
+
token = acquired == true ? nil : acquired
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
release(name: name, token: token)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def lock_key(name)
|
|
73
|
+
name.to_s.bytes.reduce(0) { |acc, b| ((acc * 31) + b) & 0x7FFFFFFF }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def redis_key(name)
|
|
77
|
+
"legion:lock:#{name}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def acquire_redis(name:, ttl:)
|
|
81
|
+
client = Legion::Cache::Redis.client
|
|
82
|
+
token = SecureRandom.hex(16)
|
|
83
|
+
key = redis_key(name)
|
|
84
|
+
result = client.call('SET', key, token, 'NX', 'PX', ttl * 1000)
|
|
85
|
+
return nil unless result
|
|
86
|
+
|
|
87
|
+
store_token(name, token)
|
|
88
|
+
token
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def release_redis(name:, token:)
|
|
94
|
+
client = Legion::Cache::Redis.client
|
|
95
|
+
tok = token || fetch_token(name)
|
|
96
|
+
return false unless tok
|
|
97
|
+
|
|
98
|
+
key = redis_key(name)
|
|
99
|
+
lua = <<~LUA
|
|
100
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
101
|
+
redis.call('DEL', KEYS[1])
|
|
102
|
+
return 1
|
|
103
|
+
else
|
|
104
|
+
return 0
|
|
105
|
+
end
|
|
106
|
+
LUA
|
|
107
|
+
result = client.call('EVAL', lua, 1, key, tok)
|
|
108
|
+
delete_token(name)
|
|
109
|
+
result == 1
|
|
110
|
+
rescue StandardError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def acquire_postgres(name:)
|
|
115
|
+
key = lock_key(name)
|
|
116
|
+
db = Legion::Data.connection
|
|
117
|
+
return false unless db
|
|
118
|
+
|
|
119
|
+
db.fetch('SELECT pg_try_advisory_lock(?) AS acquired', key).first[:acquired]
|
|
120
|
+
rescue StandardError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def release_postgres(name:)
|
|
125
|
+
key = lock_key(name)
|
|
126
|
+
db = Legion::Data.connection
|
|
127
|
+
return false unless db
|
|
128
|
+
|
|
129
|
+
db.fetch('SELECT pg_advisory_unlock(?) AS released', key).first[:released]
|
|
130
|
+
rescue StandardError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def store_token(name, token)
|
|
135
|
+
if defined?(Concurrent::Map)
|
|
136
|
+
@tokens[name.to_s] = token
|
|
137
|
+
else
|
|
138
|
+
@tokens_mutex.synchronize { @tokens[name.to_s] = token }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def fetch_token(name)
|
|
143
|
+
if defined?(Concurrent::Map)
|
|
144
|
+
@tokens[name.to_s]
|
|
145
|
+
else
|
|
146
|
+
@tokens_mutex.synchronize { @tokens[name.to_s] }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def delete_token(name)
|
|
151
|
+
if defined?(Concurrent::Map)
|
|
152
|
+
@tokens.delete(name.to_s)
|
|
153
|
+
else
|
|
154
|
+
@tokens_mutex.synchronize { @tokens.delete(name.to_s) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -70,6 +70,7 @@ module Legion
|
|
|
70
70
|
next
|
|
71
71
|
end
|
|
72
72
|
Catalog.transition(gem_name, :loaded)
|
|
73
|
+
register_in_registry(gem_name: gem_name, version: entry[:version])
|
|
73
74
|
@loaded_extensions.push(gem_name)
|
|
74
75
|
end
|
|
75
76
|
Legion::Logging.info(
|
|
@@ -227,8 +228,41 @@ module Legion
|
|
|
227
228
|
end
|
|
228
229
|
end
|
|
229
230
|
|
|
231
|
+
def register_in_registry(gem_name:, version: nil, description: nil)
|
|
232
|
+
return unless defined?(Legion::Registry)
|
|
233
|
+
return if Legion::Registry.lookup(gem_name)
|
|
234
|
+
|
|
235
|
+
capabilities = read_gemspec_capabilities(gem_name)
|
|
236
|
+
entry = Legion::Registry::Entry.new(
|
|
237
|
+
name: gem_name,
|
|
238
|
+
version: version,
|
|
239
|
+
description: description,
|
|
240
|
+
capabilities: capabilities,
|
|
241
|
+
airb_status: 'pending',
|
|
242
|
+
risk_tier: 'low'
|
|
243
|
+
)
|
|
244
|
+
Legion::Registry.register(entry)
|
|
245
|
+
register_sandbox_policy(gem_name: gem_name, capabilities: capabilities)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def register_sandbox_policy(gem_name:, capabilities: [])
|
|
249
|
+
return unless defined?(Legion::Sandbox)
|
|
250
|
+
|
|
251
|
+
Legion::Sandbox.register_policy(gem_name, capabilities: capabilities)
|
|
252
|
+
end
|
|
253
|
+
|
|
230
254
|
private
|
|
231
255
|
|
|
256
|
+
def read_gemspec_capabilities(gem_name)
|
|
257
|
+
spec = Gem::Specification.find_by_name(gem_name)
|
|
258
|
+
raw = spec.metadata['legion.capabilities']
|
|
259
|
+
return [] unless raw
|
|
260
|
+
|
|
261
|
+
raw.split(',').map(&:strip)
|
|
262
|
+
rescue Gem::MissingSpecError
|
|
263
|
+
[]
|
|
264
|
+
end
|
|
265
|
+
|
|
232
266
|
def hook_subscription_actor(extension_hash, size, opts)
|
|
233
267
|
ext_name = extension_hash[:extension_name]
|
|
234
268
|
extension = extension_hash[:extension]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module ProcessRole
|
|
5
|
+
ROLES = {
|
|
6
|
+
full: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true },
|
|
7
|
+
api: { transport: true, cache: true, data: true, extensions: false, api: true, llm: false, gaia: false, crypt: true, supervision: false },
|
|
8
|
+
worker: { transport: true, cache: true, data: true, extensions: true, api: false, llm: true, gaia: true, crypt: true, supervision: true },
|
|
9
|
+
router: { transport: true, cache: true, data: false, extensions: true, api: false, llm: false, gaia: false, crypt: true, supervision: false }
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def self.resolve(role_name)
|
|
13
|
+
key = role_name.to_sym
|
|
14
|
+
unless ROLES.key?(key)
|
|
15
|
+
warn_unrecognized(key)
|
|
16
|
+
key = :full
|
|
17
|
+
end
|
|
18
|
+
ROLES[key]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.current
|
|
22
|
+
settings = Legion::Settings[:process] rescue nil # rubocop:disable Style/RescueModifier
|
|
23
|
+
return :full unless settings.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
role = settings[:role]
|
|
26
|
+
return :full if role.nil?
|
|
27
|
+
|
|
28
|
+
role.to_sym
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.role?(name)
|
|
32
|
+
current == name.to_sym
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.warn_unrecognized(key)
|
|
36
|
+
message = "ProcessRole: unrecognized role '#{key}', falling back to :full"
|
|
37
|
+
if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
|
|
38
|
+
Legion::Logging.warn(message)
|
|
39
|
+
else
|
|
40
|
+
warn "[Legion] #{message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
private_class_method :warn_unrecognized
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Registry
|
|
5
|
+
module Governance
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
require_airb_approval: false,
|
|
8
|
+
auto_approve_risk_tiers: %w[low],
|
|
9
|
+
review_required_risk_tiers: %w[medium high critical],
|
|
10
|
+
naming_convention: 'lex-[a-z][a-z0-9_]*',
|
|
11
|
+
deprecation_notice_days: 30
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def config
|
|
16
|
+
@config ||= load_config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check_name(name)
|
|
20
|
+
pattern = Regexp.new("\\A#{config[:naming_convention]}\\z")
|
|
21
|
+
pattern.match?(name.to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def auto_approve?(risk_tier)
|
|
25
|
+
config[:auto_approve_risk_tiers].include?(risk_tier.to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def review_required?(risk_tier)
|
|
29
|
+
config[:review_required_risk_tiers].include?(risk_tier.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset!
|
|
33
|
+
@config = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load_config
|
|
39
|
+
return DEFAULTS unless defined?(Legion::Settings)
|
|
40
|
+
|
|
41
|
+
overrides = Legion::Settings.dig(:registry, :governance)
|
|
42
|
+
return DEFAULTS.merge(overrides) if overrides.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
DEFAULTS
|
|
45
|
+
rescue StandardError
|
|
46
|
+
DEFAULTS
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Registry
|
|
5
|
+
module Persistence
|
|
6
|
+
class << self
|
|
7
|
+
def data_available?
|
|
8
|
+
return false unless defined?(Legion::Data)
|
|
9
|
+
return false unless Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
10
|
+
|
|
11
|
+
Legion::Data.connection.table_exists?(:extensions_registry)
|
|
12
|
+
rescue StandardError
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load_from_db
|
|
17
|
+
return 0 unless data_available?
|
|
18
|
+
|
|
19
|
+
count = 0
|
|
20
|
+
registry_dataset.each do |row|
|
|
21
|
+
entry = Entry.new(
|
|
22
|
+
name: row[:name],
|
|
23
|
+
version: row[:version],
|
|
24
|
+
author: row[:author],
|
|
25
|
+
description: row[:description],
|
|
26
|
+
status: row[:status]&.to_sym,
|
|
27
|
+
airb_status: row[:airb_status],
|
|
28
|
+
risk_tier: row[:risk_tier]
|
|
29
|
+
)
|
|
30
|
+
Legion::Registry.register(entry)
|
|
31
|
+
count += 1
|
|
32
|
+
end
|
|
33
|
+
count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def persist(entry)
|
|
37
|
+
return false unless data_available?
|
|
38
|
+
|
|
39
|
+
attrs = persistence_attrs(entry)
|
|
40
|
+
existing = registry_dataset.where(name: entry.name).first
|
|
41
|
+
|
|
42
|
+
if existing
|
|
43
|
+
registry_dataset.where(name: entry.name).update(**attrs, updated_at: Time.now)
|
|
44
|
+
else
|
|
45
|
+
registry_dataset.insert(**attrs, created_at: Time.now, updated_at: Time.now)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
Legion::Logging.warn("Registry::Persistence failed to persist #{entry.name}: #{e.message}") if defined?(Legion::Logging)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def registry_dataset
|
|
57
|
+
Legion::Data.connection[:extensions_registry]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def persistence_attrs(entry)
|
|
61
|
+
parts = entry.name.to_s.split('-')
|
|
62
|
+
mod_name = parts.map(&:capitalize).join('::')
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
name: entry.name,
|
|
66
|
+
module_name: mod_name,
|
|
67
|
+
status: entry.status.to_s,
|
|
68
|
+
description: entry.description
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -5,10 +5,21 @@ require 'digest'
|
|
|
5
5
|
module Legion
|
|
6
6
|
module Registry
|
|
7
7
|
class SecurityScanner
|
|
8
|
-
CHECKS = %i[checksum naming_convention gemspec_metadata].freeze
|
|
8
|
+
CHECKS = %i[checksum naming_convention gemspec_metadata static_analysis].freeze
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
DANGEROUS_PATTERNS = [
|
|
11
|
+
{ pattern: /\bKernel\.eval\b|\beval\s*\(/, label: 'eval' },
|
|
12
|
+
{ pattern: /\bKernel\.system\b|\bsystem\s*\(/, label: 'system' },
|
|
13
|
+
{ pattern: /\bKernel\.exec\b|\bexec\s*\(/, label: 'exec' },
|
|
14
|
+
{ pattern: /\bIO\.popen\b/, label: 'IO.popen' },
|
|
15
|
+
{ pattern: /\bOpen3\b/, label: 'Open3' },
|
|
16
|
+
{ pattern: /`[^`]+`/, label: 'backtick subshell' }
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def scan(gem_path: nil, name: nil, gemspec: nil, source_path: nil)
|
|
20
|
+
results = CHECKS.map do |check|
|
|
21
|
+
send(check, gem_path: gem_path, name: name, gemspec: gemspec, source_path: source_path)
|
|
22
|
+
end
|
|
12
23
|
{
|
|
13
24
|
passed: results.all? { |r| r[:status] != :fail },
|
|
14
25
|
checks: results,
|
|
@@ -43,6 +54,26 @@ module Legion
|
|
|
43
54
|
{ check: :gemspec_metadata, status: status,
|
|
44
55
|
details: has_caps ? 'capabilities declared' : 'no capabilities declared' }
|
|
45
56
|
end
|
|
57
|
+
|
|
58
|
+
def static_analysis(source_path:, **_)
|
|
59
|
+
return { check: :static_analysis, status: :skip, details: 'no source path' } unless source_path && Dir.exist?(source_path.to_s)
|
|
60
|
+
|
|
61
|
+
findings = []
|
|
62
|
+
Dir.glob(File.join(source_path, '**', '*.rb')).each do |file|
|
|
63
|
+
relative = file.delete_prefix("#{source_path}/")
|
|
64
|
+
File.foreach(file).with_index(1) do |line, lineno|
|
|
65
|
+
DANGEROUS_PATTERNS.each do |entry|
|
|
66
|
+
findings << "#{relative}:#{lineno} #{entry[:label]}" if line.match?(entry[:pattern])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if findings.empty?
|
|
72
|
+
{ check: :static_analysis, status: :pass, details: 'no dangerous patterns found' }
|
|
73
|
+
else
|
|
74
|
+
{ check: :static_analysis, status: :warn, details: findings.join('; ') }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
46
77
|
end
|
|
47
78
|
end
|
|
48
79
|
end
|
data/lib/legion/registry.rb
CHANGED
|
@@ -40,7 +40,15 @@ module Legion
|
|
|
40
40
|
|
|
41
41
|
class << self
|
|
42
42
|
def register(entry)
|
|
43
|
+
raise ArgumentError, "Extension name '#{entry.name}' violates naming convention" if defined?(Governance) && !Governance.check_name(entry.name)
|
|
44
|
+
|
|
43
45
|
store[entry.name] = entry
|
|
46
|
+
|
|
47
|
+
if defined?(Governance) && Governance.auto_approve?(entry.risk_tier)
|
|
48
|
+
update_entry(entry.name, entry, status: :approved, airb_status: 'approved', approved_at: Time.now.utc)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Persistence.persist(store[entry.name]) if defined?(Persistence)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
def unregister(name)
|
|
@@ -147,7 +155,11 @@ module Legion
|
|
|
147
155
|
def update_entry(name, entry, **overrides)
|
|
148
156
|
attrs = entry.to_h.merge(overrides)
|
|
149
157
|
store[name.to_s] = Entry.new(**attrs)
|
|
158
|
+
Persistence.persist(store[name.to_s]) if defined?(Persistence)
|
|
150
159
|
end
|
|
151
160
|
end
|
|
152
161
|
end
|
|
153
162
|
end
|
|
163
|
+
|
|
164
|
+
require_relative 'registry/persistence'
|
|
165
|
+
require_relative 'registry/governance'
|
data/lib/legion/service.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'readiness'
|
|
4
|
+
require_relative 'process_role'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
class Service
|
|
@@ -11,8 +12,20 @@ module Legion
|
|
|
11
12
|
base.freeze
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def initialize(transport:
|
|
15
|
-
crypt:
|
|
15
|
+
def initialize(transport: nil, cache: nil, data: nil, supervision: nil, extensions: nil, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists,Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/AbcSize
|
|
16
|
+
crypt: nil, api: nil, llm: nil, gaia: nil, log_level: 'info', http_port: nil,
|
|
17
|
+
role: nil)
|
|
18
|
+
role_opts = Legion::ProcessRole.resolve(role || Legion::ProcessRole.current)
|
|
19
|
+
transport = role_opts[:transport] if transport.nil?
|
|
20
|
+
cache = role_opts[:cache] if cache.nil?
|
|
21
|
+
data = role_opts[:data] if data.nil?
|
|
22
|
+
supervision = role_opts[:supervision] if supervision.nil?
|
|
23
|
+
extensions = role_opts[:extensions] if extensions.nil?
|
|
24
|
+
crypt = role_opts[:crypt] if crypt.nil?
|
|
25
|
+
api = role_opts[:api] if api.nil?
|
|
26
|
+
llm = role_opts[:llm] if llm.nil?
|
|
27
|
+
gaia = role_opts[:gaia] if gaia.nil?
|
|
28
|
+
|
|
16
29
|
setup_logging(log_level: log_level)
|
|
17
30
|
Legion::Logging.debug('Starting Legion::Service')
|
|
18
31
|
setup_settings
|
data/lib/legion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.109
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -483,6 +483,7 @@ files:
|
|
|
483
483
|
- lib/legion/cli/dataset_command.rb
|
|
484
484
|
- lib/legion/cli/detect_command.rb
|
|
485
485
|
- lib/legion/cli/docs_command.rb
|
|
486
|
+
- lib/legion/cli/doctor.rb
|
|
486
487
|
- lib/legion/cli/doctor/bundle_check.rb
|
|
487
488
|
- lib/legion/cli/doctor/cache_check.rb
|
|
488
489
|
- lib/legion/cli/doctor/config_check.rb
|
|
@@ -587,6 +588,9 @@ files:
|
|
|
587
588
|
- lib/legion/cli/update_command.rb
|
|
588
589
|
- lib/legion/cli/version.rb
|
|
589
590
|
- lib/legion/cli/worker_command.rb
|
|
591
|
+
- lib/legion/cluster.rb
|
|
592
|
+
- lib/legion/cluster/leader.rb
|
|
593
|
+
- lib/legion/cluster/lock.rb
|
|
590
594
|
- lib/legion/context.rb
|
|
591
595
|
- lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb
|
|
592
596
|
- lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb
|
|
@@ -647,8 +651,11 @@ files:
|
|
|
647
651
|
- lib/legion/phi/access_log.rb
|
|
648
652
|
- lib/legion/phi/erasure.rb
|
|
649
653
|
- lib/legion/process.rb
|
|
654
|
+
- lib/legion/process_role.rb
|
|
650
655
|
- lib/legion/readiness.rb
|
|
651
656
|
- lib/legion/registry.rb
|
|
657
|
+
- lib/legion/registry/governance.rb
|
|
658
|
+
- lib/legion/registry/persistence.rb
|
|
652
659
|
- lib/legion/registry/security_scanner.rb
|
|
653
660
|
- lib/legion/runner.rb
|
|
654
661
|
- lib/legion/runner/log.rb
|