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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0acca30b533051c884363e6c9215394d308f501985a6dc647f92c513e875ce6b
4
- data.tar.gz: 862c366ebcb1f88a66eb5c30b48086e454145609d57f1fbb8ba6aa62aeb89652
3
+ metadata.gz: 9e4f14b45f5ece26ac4279095f818d8772efcf5ae0d1dfb95924e2f69a2c2bcc
4
+ data.tar.gz: a51ac4d001f7e5c5c17491fdb168da11b04d17502972d80d1119ac99f2243acf
5
5
  SHA512:
6
- metadata.gz: 9066154fcf88d0e39457fca4c4f4ebac74e7104c9d5d2bf047783e2f058ddfc9b03e38be965f492ac914b81eb611da50cff7e63264e6a16caadf0e23d8d707ef
7
- data.tar.gz: b7324da5f140698a74d11e94dedee3a3bdc2d778a9f0f2ca12417861bb5a5b23ad96eb6f4ad94077c1b6f72cbc2f2e0c3651c62d08242452451d7f0ada75d330
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'
@@ -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
- def install_from_archive(archive_data, layer_override: nil)
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
- raise ArgumentError, "SkillSet '#{name}' already installed at #{dest}" if File.directory?(dest)
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
- FileUtils.mkdir_p(@skillsets_dir)
334
- FileUtils.cp_r(extracted, dest)
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)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.11.0"
2
+ VERSION = "3.12.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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