kairos-chain 3.11.0 → 3.12.0
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 +36 -0
- data/lib/kairos_mcp/http_server.rb +27 -0
- data/lib/kairos_mcp/skillset.rb +6 -0
- data/lib/kairos_mcp/skillset_manager.rb +77 -6
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/hestia/lib/hestia/place_router.rb +32 -2
- data/templates/skillsets/hestia/tools/meeting_place_start.rb +28 -0
- data/templates/skillsets/skillset_exchange/config/skillset_exchange.yml +15 -0
- data/templates/skillsets/skillset_exchange/knowledge/skillset_exchange_guide/skillset_exchange_guide.md +48 -0
- data/templates/skillsets/skillset_exchange/lib/skillset_exchange/exchange_validator.rb +58 -0
- data/templates/skillsets/skillset_exchange/lib/skillset_exchange/place_extension.rb +717 -0
- data/templates/skillsets/skillset_exchange/skillset.json +32 -0
- data/templates/skillsets/skillset_exchange/tools/skillset_acquire.rb +328 -0
- data/templates/skillsets/skillset_exchange/tools/skillset_browse.rb +131 -0
- data/templates/skillsets/skillset_exchange/tools/skillset_deposit.rb +184 -0
- data/templates/skillsets/skillset_exchange/tools/skillset_withdraw.rb +149 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e4f14b45f5ece26ac4279095f818d8772efcf5ae0d1dfb95924e2f69a2c2bcc
|
|
4
|
+
data.tar.gz: a51ac4d001f7e5c5c17491fdb168da11b04d17502972d80d1119ac99f2243acf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cb3cba2544bbb1244945199b783db04da81573dfde6d45d8c1c46d0c310e0fe26e9c521b7983263e66ec2d363c5614f630336ecdd10af056ca3ea9cb8b5ccf6
|
|
7
|
+
data.tar.gz: 64ef0f93ebc9922c5acc8bbc6f0733734e9be5445c810f51f1c2b4f55c975764f39277bf07d48a5d1477010fdb7b1c7dfe9452f88bcf3db0ed8bbd1339caf3f9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.12.0] - 2026-04-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`skillset_exchange` SkillSet** (v0.1.0) — New L1 SkillSet enabling knowledge-only
|
|
12
|
+
SkillSet deposit, browse, acquire, and withdraw via HestiaChain Meeting Places.
|
|
13
|
+
- **PlaceRouter extension mechanism** — Formal extension registry with `register_extension`,
|
|
14
|
+
`route_action_map`, dispatch after auth/middleware. Enables SkillSets to add HTTP
|
|
15
|
+
endpoints to HestiaChain PlaceRouter without modifying Hestia core.
|
|
16
|
+
- **4 MCP tools**: `skillset_deposit`, `skillset_browse`, `skillset_acquire`, `skillset_withdraw`
|
|
17
|
+
- **PlaceExtension**: 4 HTTP endpoints (`/place/v1/skillset_deposit`, `skillset_browse`,
|
|
18
|
+
`skillset_content`, `skillset_withdraw`) with disk-backed storage and quotas
|
|
19
|
+
- **Security**: Dual executable gate (server tar header scan + client `knowledge_only?`),
|
|
20
|
+
content hash verification (file-tree hash), signature verification with `require_signature`
|
|
21
|
+
enforcement, path traversal protection, Content-Length pre-check, metadata canonicalization
|
|
22
|
+
from verified archive contents
|
|
23
|
+
- **DEE compliance**: Random sampling for browse, no ranking or popularity metrics
|
|
24
|
+
- **ExchangeValidator**: Single gatekeeper for deposit eligibility
|
|
25
|
+
- Lazy extension registration for late SkillSet enablement
|
|
26
|
+
- Configurable quotas: 5MB/archive, 10/agent, 100MB total (default)
|
|
27
|
+
- **`SkillSetManager#install_from_archive` `force:` parameter** — Atomic swap reinstall
|
|
28
|
+
with config preservation for SkillSet upgrades via exchange.
|
|
29
|
+
- **`SkillSetManager#check_installable_dependencies`** — Public preflight method returning
|
|
30
|
+
structured result (`satisfiable`, `missing`, `version_mismatch`, `disabled`) without raising.
|
|
31
|
+
- **`Skillset#place_extensions`** — Metadata accessor for PlaceRouter extension declarations.
|
|
32
|
+
|
|
33
|
+
### Design Process
|
|
34
|
+
|
|
35
|
+
- Design: 2 rounds x 3 LLMs → APPROVED (0 FAIL)
|
|
36
|
+
- Phase 1 impl: 1R x 3 LLMs → fixes applied
|
|
37
|
+
- Phase 2 impl: 2R x 3 LLMs → fixes applied (Codex REJECT resolved)
|
|
38
|
+
- Phase 3 design: 2R x 3 LLMs → APPROVED
|
|
39
|
+
- Phase 3 impl: 1R x 3 LLMs → fixes applied
|
|
40
|
+
- Final comprehensive: 1R x 3 LLMs → fixes applied (SecurityError, metadata canonicalization)
|
|
41
|
+
- Tests: 303 total (Phase 1: 44, Phase 2: 85, Phase 3: 80, Phase 4: 94)
|
|
42
|
+
|
|
7
43
|
## [3.11.0] - 2026-04-01
|
|
8
44
|
|
|
9
45
|
### Added
|
|
@@ -283,6 +283,9 @@ module KairosMcp
|
|
|
283
283
|
trust_anchor_client: trust_anchor_client
|
|
284
284
|
)
|
|
285
285
|
@place_router = router
|
|
286
|
+
|
|
287
|
+
# Register place extensions from enabled SkillSets
|
|
288
|
+
register_place_extensions(router)
|
|
286
289
|
end
|
|
287
290
|
|
|
288
291
|
# -----------------------------------------------------------------------
|
|
@@ -295,6 +298,30 @@ module KairosMcp
|
|
|
295
298
|
|
|
296
299
|
private
|
|
297
300
|
|
|
301
|
+
# Register place extensions from enabled SkillSets that declare place_extensions.
|
|
302
|
+
# Uses KairosMcp.http_server pattern for late registration access.
|
|
303
|
+
def register_place_extensions(router)
|
|
304
|
+
require_relative 'skillset_manager'
|
|
305
|
+
SkillSetManager.new.enabled_skillsets.each do |ss|
|
|
306
|
+
next unless ss.place_extensions.any?
|
|
307
|
+
|
|
308
|
+
ss.place_extensions.each do |ext_def|
|
|
309
|
+
require_path = File.join(ss.path, ext_def['require'])
|
|
310
|
+
require require_path
|
|
311
|
+
ext_class = Object.const_get(ext_def['class'])
|
|
312
|
+
router.register_extension(
|
|
313
|
+
ext_class.new(router),
|
|
314
|
+
route_action_map: ext_def['route_actions'] || {}
|
|
315
|
+
)
|
|
316
|
+
rescue StandardError => e
|
|
317
|
+
$stderr.puts "[HttpServer] Failed to load extension '#{ext_def['class']}' " \
|
|
318
|
+
"from SkillSet '#{ss.name}': #{e.message}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
$stderr.puts "[HttpServer] Extension registration failed (non-fatal): #{e.message}"
|
|
323
|
+
end
|
|
324
|
+
|
|
298
325
|
# Auto-start Meeting Place if hestia.yml has meeting_place.enabled: true
|
|
299
326
|
def auto_start_meeting_place
|
|
300
327
|
require 'hestia'
|
data/lib/kairos_mcp/skillset.rb
CHANGED
|
@@ -78,6 +78,12 @@ module KairosMcp
|
|
|
78
78
|
@metadata['knowledge_dirs'] || []
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Place extension declarations for PlaceRouter integration.
|
|
82
|
+
# Returns Array of Hashes, each with 'class', 'require', 'route_actions'.
|
|
83
|
+
def place_extensions
|
|
84
|
+
@metadata['place_extensions'] || []
|
|
85
|
+
end
|
|
86
|
+
|
|
81
87
|
def index_knowledge?
|
|
82
88
|
return @metadata['index_knowledge'] == true if @metadata.key?('index_knowledge')
|
|
83
89
|
|
|
@@ -294,8 +294,9 @@ module KairosMcp
|
|
|
294
294
|
}
|
|
295
295
|
end
|
|
296
296
|
|
|
297
|
-
# Install a SkillSet from a Base64-encoded tar.gz archive
|
|
298
|
-
|
|
297
|
+
# Install a SkillSet from a Base64-encoded tar.gz archive.
|
|
298
|
+
# With force: true, replaces an existing SkillSet (preserves config/ files).
|
|
299
|
+
def install_from_archive(archive_data, layer_override: nil, force: false)
|
|
299
300
|
archive_data = symbolize_keys(archive_data)
|
|
300
301
|
name = archive_data[:name]
|
|
301
302
|
raise ArgumentError, "Archive missing 'name'" unless name
|
|
@@ -303,7 +304,9 @@ module KairosMcp
|
|
|
303
304
|
raise ArgumentError, "Archive missing 'archive_base64'" unless archive_data[:archive_base64]
|
|
304
305
|
|
|
305
306
|
dest = File.join(@skillsets_dir, name)
|
|
306
|
-
|
|
307
|
+
if File.directory?(dest) && !force
|
|
308
|
+
raise ArgumentError, "SkillSet '#{name}' already installed at #{dest}. Use force: true to reinstall."
|
|
309
|
+
end
|
|
307
310
|
|
|
308
311
|
Dir.mktmpdir('kairos_ss_install') do |tmpdir|
|
|
309
312
|
tar_gz_data = Base64.strict_decode64(archive_data[:archive_base64])
|
|
@@ -330,21 +333,89 @@ module KairosMcp
|
|
|
330
333
|
end
|
|
331
334
|
end
|
|
332
335
|
|
|
333
|
-
|
|
334
|
-
|
|
336
|
+
# Force reinstall: stage in temp dir (already done above), then atomic swap
|
|
337
|
+
if File.directory?(dest) && force
|
|
338
|
+
# Preserve user config files
|
|
339
|
+
config_dir = File.join(dest, 'config')
|
|
340
|
+
saved_configs = {}
|
|
341
|
+
if File.directory?(config_dir)
|
|
342
|
+
Dir.glob(File.join(config_dir, '*')).each do |f|
|
|
343
|
+
saved_configs[File.basename(f)] = File.read(f) if File.file?(f)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Atomic swap: remove old, move new into place
|
|
348
|
+
FileUtils.rm_rf(dest)
|
|
349
|
+
FileUtils.cp_r(extracted, dest)
|
|
350
|
+
|
|
351
|
+
# Restore preserved config
|
|
352
|
+
unless saved_configs.empty?
|
|
353
|
+
FileUtils.mkdir_p(File.join(dest, 'config'))
|
|
354
|
+
saved_configs.each do |fname, content|
|
|
355
|
+
File.write(File.join(dest, 'config', fname), content)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
else
|
|
359
|
+
FileUtils.mkdir_p(@skillsets_dir)
|
|
360
|
+
FileUtils.cp_r(extracted, dest)
|
|
361
|
+
end
|
|
335
362
|
|
|
336
363
|
installed = Skillset.new(dest)
|
|
337
364
|
installed.layer = layer_override if layer_override
|
|
338
365
|
|
|
339
366
|
set_config(installed.name, 'layer_override', layer_override.to_s) if layer_override
|
|
340
367
|
set_config(installed.name, 'enabled', true)
|
|
341
|
-
record_skillset_event(installed, 'install_from_archive')
|
|
368
|
+
record_skillset_event(installed, force ? 'reinstall_from_archive' : 'install_from_archive')
|
|
342
369
|
|
|
343
370
|
{ success: true, name: installed.name, version: installed.version,
|
|
344
371
|
layer: installed.layer, path: dest, content_hash: installed.content_hash }
|
|
345
372
|
end
|
|
346
373
|
end
|
|
347
374
|
|
|
375
|
+
# Check if a SkillSet's dependencies can be satisfied locally.
|
|
376
|
+
# Returns a structured result (does NOT raise).
|
|
377
|
+
#
|
|
378
|
+
# @param skillset [Skillset] A Skillset object (from archive or installed)
|
|
379
|
+
# @return [Hash] { satisfiable:, missing:, version_mismatch:, disabled: }
|
|
380
|
+
def check_installable_dependencies(skillset)
|
|
381
|
+
result = { satisfiable: true, missing: [], version_mismatch: [], disabled: [] }
|
|
382
|
+
skillset.depends_on_with_versions.each do |dep|
|
|
383
|
+
installed = find_skillset(dep[:name])
|
|
384
|
+
if installed.nil?
|
|
385
|
+
result[:satisfiable] = false
|
|
386
|
+
result[:missing] << dep[:name]
|
|
387
|
+
next
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
unless enabled?(dep[:name])
|
|
391
|
+
result[:disabled] << dep[:name]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
next unless dep[:version]
|
|
395
|
+
|
|
396
|
+
begin
|
|
397
|
+
requirement = Gem::Requirement.new(*dep[:version].split(',').map(&:strip))
|
|
398
|
+
unless requirement.satisfied_by?(Gem::Version.new(installed.version))
|
|
399
|
+
result[:satisfiable] = false
|
|
400
|
+
result[:version_mismatch] << {
|
|
401
|
+
name: dep[:name],
|
|
402
|
+
required: dep[:version],
|
|
403
|
+
installed: installed.version
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
rescue Gem::Requirement::BadRequirementError
|
|
407
|
+
result[:satisfiable] = false
|
|
408
|
+
result[:version_mismatch] << {
|
|
409
|
+
name: dep[:name],
|
|
410
|
+
required: dep[:version],
|
|
411
|
+
installed: installed.version,
|
|
412
|
+
error: 'invalid version constraint'
|
|
413
|
+
}
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
result
|
|
417
|
+
end
|
|
418
|
+
|
|
348
419
|
# Get info about a specific SkillSet
|
|
349
420
|
def info(name)
|
|
350
421
|
skillset = find_skillset(name)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Hestia
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at
|
|
58
|
+
attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at, :extensions
|
|
59
59
|
|
|
60
60
|
def initialize(config: nil)
|
|
61
61
|
@config = config || ::Hestia.load_config
|
|
@@ -66,6 +66,21 @@ module Hestia
|
|
|
66
66
|
@started = false
|
|
67
67
|
@started_at = nil
|
|
68
68
|
@self_id = nil
|
|
69
|
+
@extensions = []
|
|
70
|
+
@extension_action_map = {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Register a PlaceRouter extension for additional endpoint handling.
|
|
74
|
+
# Extensions receive authenticated requests and return Rack responses or nil.
|
|
75
|
+
# Idempotent: skips if an extension of the same class is already registered.
|
|
76
|
+
#
|
|
77
|
+
# @param extension [Object] Extension instance responding to #call(env, peer_id:)
|
|
78
|
+
# @param route_action_map [Hash] Maps route segments to action names for access control
|
|
79
|
+
def register_extension(extension, route_action_map: {})
|
|
80
|
+
return if @extensions.any? { |e| e.class == extension.class }
|
|
81
|
+
|
|
82
|
+
@extensions << extension
|
|
83
|
+
@extension_action_map.merge!(route_action_map)
|
|
69
84
|
end
|
|
70
85
|
|
|
71
86
|
# Start the Meeting Place: initialize components and self-register.
|
|
@@ -203,6 +218,10 @@ module Hestia
|
|
|
203
218
|
elsif request_method == 'GET' && path.start_with?('/place/v1/agent_profile/')
|
|
204
219
|
handle_agent_profile(path)
|
|
205
220
|
else
|
|
221
|
+
# Extension dispatch: check registered extensions before 404
|
|
222
|
+
ext_result = dispatch_extensions(env, peer_id: peer_id)
|
|
223
|
+
return ext_result if ext_result
|
|
224
|
+
|
|
206
225
|
json_response(404, { error: 'not_found', message: "Unknown place endpoint: #{path}" })
|
|
207
226
|
end
|
|
208
227
|
end
|
|
@@ -859,8 +878,9 @@ module Hestia
|
|
|
859
878
|
end
|
|
860
879
|
|
|
861
880
|
# Map route segment to abstract action name for access control.
|
|
881
|
+
# Checks built-in ROUTE_ACTION_MAP first, then extension action map.
|
|
862
882
|
def resolve_action(route_segment)
|
|
863
|
-
ROUTE_ACTION_MAP[route_segment] || route_segment
|
|
883
|
+
ROUTE_ACTION_MAP[route_segment] || @extension_action_map[route_segment] || route_segment
|
|
864
884
|
end
|
|
865
885
|
|
|
866
886
|
# Run registered place middlewares. Returns nil if all pass,
|
|
@@ -876,6 +896,16 @@ module Hestia
|
|
|
876
896
|
nil
|
|
877
897
|
end
|
|
878
898
|
|
|
899
|
+
# Dispatch request to registered extensions.
|
|
900
|
+
# Returns Rack response if an extension handles it, nil otherwise.
|
|
901
|
+
def dispatch_extensions(env, peer_id:)
|
|
902
|
+
@extensions.each do |ext|
|
|
903
|
+
result = ext.call(env, peer_id: peer_id)
|
|
904
|
+
return result if result
|
|
905
|
+
end
|
|
906
|
+
nil
|
|
907
|
+
end
|
|
908
|
+
|
|
879
909
|
# Service name for this Meeting Place instance
|
|
880
910
|
def service_name
|
|
881
911
|
@config.dig('meeting_place', 'service_name') || 'meeting_place'
|
|
@@ -90,6 +90,9 @@ module KairosMcp
|
|
|
90
90
|
trust_scorer: scorer
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
+
# Register place extensions from enabled SkillSets
|
|
94
|
+
register_extensions(place_router)
|
|
95
|
+
|
|
93
96
|
text_content(JSON.pretty_generate(result))
|
|
94
97
|
rescue StandardError => e
|
|
95
98
|
text_content(JSON.pretty_generate({
|
|
@@ -100,6 +103,31 @@ module KairosMcp
|
|
|
100
103
|
|
|
101
104
|
private
|
|
102
105
|
|
|
106
|
+
# Register place extensions from enabled SkillSets that declare place_extensions.
|
|
107
|
+
# Iterates all enabled SkillSets, requires extension files, and registers with the router.
|
|
108
|
+
# Errors are logged but do not block Place startup.
|
|
109
|
+
def register_extensions(router)
|
|
110
|
+
manager = ::KairosMcp::SkillSetManager.new
|
|
111
|
+
manager.enabled_skillsets.each do |ss|
|
|
112
|
+
next unless ss.place_extensions.any?
|
|
113
|
+
|
|
114
|
+
ss.place_extensions.each do |ext_def|
|
|
115
|
+
require_path = File.join(ss.path, ext_def['require'])
|
|
116
|
+
require require_path
|
|
117
|
+
ext_class = Object.const_get(ext_def['class'])
|
|
118
|
+
router.register_extension(
|
|
119
|
+
ext_class.new(router),
|
|
120
|
+
route_action_map: ext_def['route_actions'] || {}
|
|
121
|
+
)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
$stderr.puts "[MeetingPlaceStart] Failed to load extension '#{ext_def['class']}' " \
|
|
124
|
+
"from SkillSet '#{ss.name}': #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
$stderr.puts "[MeetingPlaceStart] Extension registration failed (non-fatal): #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
103
131
|
# Runtime detection of Synoptis SkillSet.
|
|
104
132
|
# Returns a TrustScorer instance if Synoptis is loaded, nil otherwise.
|
|
105
133
|
# This follows the core_or_skillset_guide pattern for optional dependencies.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SkillSet Exchange configuration
|
|
2
|
+
enabled: true
|
|
3
|
+
|
|
4
|
+
deposit:
|
|
5
|
+
max_archive_size_bytes: 5242880 # 5MB per archive
|
|
6
|
+
max_per_agent: 10 # max SkillSet deposits per agent
|
|
7
|
+
require_signature: true # Require depositor signature
|
|
8
|
+
|
|
9
|
+
acquire:
|
|
10
|
+
verify_signature: true # Verify depositor signature on acquire
|
|
11
|
+
check_dependencies: true # Warn about missing dependencies
|
|
12
|
+
|
|
13
|
+
place:
|
|
14
|
+
max_total_archive_bytes: 104857600 # 100MB total across all agents
|
|
15
|
+
storage_dir: skillset_deposits # relative to hestia storage path
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skillset_exchange_guide
|
|
3
|
+
description: Guide for depositing, browsing, and acquiring SkillSets via Meeting Place
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
tags:
|
|
6
|
+
- skillset
|
|
7
|
+
- exchange
|
|
8
|
+
- meeting_place
|
|
9
|
+
- guide
|
|
10
|
+
type: reference
|
|
11
|
+
public: true
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# SkillSet Exchange Guide
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
The SkillSet Exchange enables agents to deposit, browse, and acquire bundled knowledge
|
|
19
|
+
packages (SkillSets) through a Meeting Place. Only knowledge-only SkillSets (no executable
|
|
20
|
+
code) are exchangeable.
|
|
21
|
+
|
|
22
|
+
## Workflow
|
|
23
|
+
|
|
24
|
+
1. **Connect** to a Meeting Place: `meeting_connect(url: "...")`
|
|
25
|
+
2. **Deposit** a knowledge SkillSet: `skillset_deposit(name: "my_knowledge_pack")`
|
|
26
|
+
3. **Browse** available SkillSets: `skillset_browse(search: "genomics")`
|
|
27
|
+
4. **Acquire** a SkillSet: `skillset_acquire(name: "...", depositor_id: "...")`
|
|
28
|
+
5. **Withdraw** a deposit: `skillset_withdraw(name: "...", reason: "...")`
|
|
29
|
+
|
|
30
|
+
## Security Model
|
|
31
|
+
|
|
32
|
+
- Only knowledge-only SkillSets can be deposited (no `.rb`, `.py`, `.sh`, etc.)
|
|
33
|
+
- Archives are scanned for executable content at deposit time (tar header scan)
|
|
34
|
+
- Content hashes are verified on both deposit and acquire
|
|
35
|
+
- Deposits are signed by the depositor's cryptographic key
|
|
36
|
+
- The acquirer's `install_from_archive` independently verifies `knowledge_only?`
|
|
37
|
+
|
|
38
|
+
## DEE Compliance
|
|
39
|
+
|
|
40
|
+
Browse results are returned in random order. No ranking, scoring, or popularity
|
|
41
|
+
metrics are exposed. Each agent decides locally whether a SkillSet is useful.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Edit `config/skillset_exchange.yml` to adjust:
|
|
46
|
+
- `deposit.max_archive_size_bytes`: Maximum archive size (default 5MB)
|
|
47
|
+
- `deposit.max_per_agent`: Maximum deposits per agent (default 10)
|
|
48
|
+
- `place.max_total_archive_bytes`: Total storage quota (default 100MB)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkillsetExchange
|
|
4
|
+
# Single gatekeeper for SkillSet deposit eligibility.
|
|
5
|
+
#
|
|
6
|
+
# Validates that a SkillSet is safe to deposit:
|
|
7
|
+
# - Name matches safe pattern
|
|
8
|
+
# - SkillSet exists and is exchangeable (knowledge_only? + valid?)
|
|
9
|
+
# - Estimated archive size within limit
|
|
10
|
+
class ExchangeValidator
|
|
11
|
+
SAFE_NAME_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
|
|
12
|
+
|
|
13
|
+
def initialize(config: {})
|
|
14
|
+
@max_archive_size = config.dig('deposit', 'max_archive_size_bytes') || 5_242_880
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate a SkillSet for deposit eligibility.
|
|
18
|
+
#
|
|
19
|
+
# @param name [String] SkillSet name
|
|
20
|
+
# @param manager [KairosMcp::SkillSetManager, nil] Manager instance (auto-created if nil)
|
|
21
|
+
# @return [Hash] { valid: Boolean, errors: Array<String> }
|
|
22
|
+
def validate_for_deposit(name, manager: nil)
|
|
23
|
+
manager ||= ::KairosMcp::SkillSetManager.new
|
|
24
|
+
errors = []
|
|
25
|
+
|
|
26
|
+
# Name validation
|
|
27
|
+
errors << "Invalid SkillSet name" unless SAFE_NAME_PATTERN.match?(name.to_s)
|
|
28
|
+
|
|
29
|
+
ss = manager.find_skillset(name)
|
|
30
|
+
errors << "SkillSet '#{name}' not found" unless ss
|
|
31
|
+
|
|
32
|
+
if ss
|
|
33
|
+
errors << "Not exchangeable: contains executable code" unless ss.exchangeable?
|
|
34
|
+
|
|
35
|
+
# Estimate archive size from file sizes (avoid packaging just to check)
|
|
36
|
+
estimated_size = estimate_archive_size(ss)
|
|
37
|
+
if estimated_size > @max_archive_size
|
|
38
|
+
errors << "Estimated archive too large (#{estimated_size} > #{@max_archive_size})"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
{ valid: errors.empty?, errors: errors }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Estimate archive size from the sum of file sizes in the SkillSet.
|
|
48
|
+
# Tar overhead is ~512 bytes per entry; gzip typically compresses text well,
|
|
49
|
+
# so raw file size is a reasonable upper bound.
|
|
50
|
+
def estimate_archive_size(skillset)
|
|
51
|
+
Dir[File.join(skillset.path, '**', '*')]
|
|
52
|
+
.select { |f| File.file?(f) }
|
|
53
|
+
.sum { |f| File.size(f) }
|
|
54
|
+
rescue StandardError
|
|
55
|
+
Float::INFINITY # fail-closed: unable to estimate size
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|