kairos-chain 3.3.1 → 3.4.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: 3bbb98fc197f37790e8cf89fa3b33320fcc344f893da8550b22c26a72d60db7b
4
- data.tar.gz: f0f7851aabb13ba39be6a0e9408c31dec843386427326bdb4f301a2adb2685ac
3
+ metadata.gz: 536074c6863ed27ee7c404595e3267fdb266ae0a23d55705ecf3566730b4b07f
4
+ data.tar.gz: 4e38e6afae0c352d900d81e607264a0d5d76505e137effdcc04df4c79ef52cd4
5
5
  SHA512:
6
- metadata.gz: c5e75b31dc9f1f511e998d58671d6d13bc7252c3b69323f5c28f5265856a80e4fcaed6f64a5ac031ef480471eb69f7a232a76569e68fdcf2899ce1338a9f2bfc
7
- data.tar.gz: f298c8d3897a4b4fc336cd79c9f9586e44200b4fb9a75ebb6ed35a320b8112b9ab56eac5ec6a53936dd549113f596b8ee3f230e2950d76d42236eede05a7d611
6
+ metadata.gz: 1b7d841dd848af30cffecb461345386cb17f9eb3def2068988f01ceca9be3411b06db9d7f6670062cef72e3aa83f920e0faed49919307b2bd7e9fbbed6eec1da
7
+ data.tar.gz: 52cd9de9f80f2fcd4cde383f9466040bac2347b0bd44d2bc20ae82ed16aa335140add8acf6414cf908eefd617a0ab35a262a0e2a1cafe4486e7a97d61394dc0c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.4.0] - 2026-03-24
8
+
9
+ ### Added
10
+
11
+ - **Attestation Deposit**: Agents can deposit signed attestation copies on skills at the
12
+ Meeting Place. Other agents see attestations in browse/preview and can verify signatures
13
+ via the attester's public key (no P2P needed).
14
+ - `POST /place/v1/board/attest` — deposit attestation with RSA signature
15
+ - `meeting_attest_skill` MCP tool — sign claim, hash evidence, deposit to Place
16
+ - 3-layer trust: Browse (metadata) → Preview (verify signature) → P2P (evidence text)
17
+ - Version-bound: attestations linked to specific `content_hash`, hidden after skill update
18
+ - Server-side signature verification against registry public key
19
+ - Cleaned up on skill withdrawal; size/rate limited
20
+ - Multi-LLM reviewed: 2 rounds × 3 LLMs, 11 findings resolved
21
+
22
+ ### Fixed
23
+
24
+ - **Meeting Place storage path**: Default paths now resolve via `KairosMcp.storage_dir`
25
+ (inside Docker volume), preventing deposit data loss on container rebuild.
26
+
27
+ ---
28
+
7
29
  ## [3.3.1] - 2026-03-24
8
30
 
9
31
  ### Fixed
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.3.1"
2
+ VERSION = "3.4.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -29,6 +29,7 @@ module Hestia
29
29
  'skill_content' => 'browse',
30
30
  'preview' => 'browse',
31
31
  'agent_profile' => 'browse',
32
+ 'attest' => 'deposit_skill',
32
33
  'needs' => 'browse',
33
34
  'agents' => 'browse',
34
35
  'keys' => 'browse',
@@ -76,7 +77,9 @@ module Hestia
76
77
  # @return [Hash] Start result
77
78
  def start(identity:, session_store:, trust_anchor_client: nil, trust_scorer: nil)
78
79
  place_config = @config['meeting_place'] || {}
79
- registry_path = place_config['registry_path'] || 'storage/agent_registry.json'
80
+ # Resolve storage paths relative to KairosMcp.data_dir (ensures Docker volume persistence)
81
+ default_storage = defined?(KairosMcp) ? File.join(KairosMcp.storage_dir, '') : 'storage/'
82
+ registry_path = place_config['registry_path'] || "#{default_storage}agent_registry.json"
80
83
 
81
84
  @session_store = session_store
82
85
  @trust_anchor_client = trust_anchor_client
@@ -85,7 +88,7 @@ module Hestia
85
88
  @self_id = intro.dig(:identity, :instance_id)
86
89
 
87
90
  deposit_policy = place_config['deposit_policy'] || {}
88
- deposit_storage = place_config['deposit_storage_path'] || 'storage/skill_board_state.json'
91
+ deposit_storage = place_config['deposit_storage_path'] || "#{default_storage}skill_board_state.json"
89
92
  federation_config = place_config['federation'] || {}
90
93
  @skill_board = SkillBoard.new(
91
94
  registry: @registry,
@@ -183,6 +186,8 @@ module Hestia
183
186
  handle_delete_needs(env)
184
187
  when ['POST', '/place/v1/deposit']
185
188
  handle_deposit(env)
189
+ when ['POST', '/place/v1/board/attest']
190
+ handle_attest(env)
186
191
  else
187
192
  # Check for pattern-based routes
188
193
  if request_method == 'GET' && path.start_with?('/place/v1/keys/')
@@ -513,6 +518,59 @@ module Hestia
513
518
  end
514
519
  end
515
520
 
521
+ # POST /place/v1/board/attest — Bearer token required
522
+ # Deposit an attestation on a skill. Stores a copy on the Place.
523
+ def handle_attest(env)
524
+ body = parse_body(env)
525
+ token = extract_bearer_token(env)
526
+ attester_id = @session_store.validate(token)
527
+
528
+ skill_id = body['skill_id']
529
+ owner_agent_id = body['owner_agent_id']
530
+ claim = body['claim']
531
+
532
+ unless skill_id && owner_agent_id && claim
533
+ return json_response(400, {
534
+ error: 'missing_fields',
535
+ message: 'skill_id, owner_agent_id, and claim are required'
536
+ })
537
+ end
538
+
539
+ # Get attester name from registry
540
+ attester_agent = @registry.get(attester_id)
541
+ attester_name = attester_agent ? attester_agent[:name] : nil
542
+
543
+ # Get attester's public key for server-side signature verification
544
+ public_key = @registry.public_key_for(attester_id)
545
+
546
+ result = @skill_board.deposit_attestation(
547
+ attester_id: attester_id,
548
+ attester_name: attester_name,
549
+ skill_id: skill_id,
550
+ owner_agent_id: owner_agent_id,
551
+ claim: claim,
552
+ evidence_hash: body['evidence_hash'],
553
+ signature: body['signature'],
554
+ signed_payload: body['signed_payload'],
555
+ public_key: public_key
556
+ )
557
+
558
+ if result[:valid]
559
+ record_chain_event(
560
+ event_type: 'attestation',
561
+ skill_id: skill_id,
562
+ skill_name: skill_id,
563
+ content_hash: body['evidence_hash'] || '',
564
+ participants: [attester_id, owner_agent_id],
565
+ extra: { attester_id: attester_id, claim: claim }
566
+ )
567
+
568
+ json_response(200, result)
569
+ else
570
+ json_response(422, result)
571
+ end
572
+ end
573
+
516
574
  # GET /place/v1/welcome — Public, no auth
517
575
  # Returns a guide for new agents joining this Meeting Place.
518
576
  def handle_welcome
@@ -44,6 +44,7 @@ module Hestia
44
44
  @exchange_counts = {} # internal_key => count
45
45
  @total_exchange_count = 0
46
46
  @deposit_timestamps = {} # agent_id => [Time] for rate limiting
47
+ @attestations = [] # attestation deposits on skills
47
48
  @self_place_id = self_place_id
48
49
  @storage_path = storage_path || 'storage/skill_board_state.json'
49
50
  @content_dir = File.join(File.dirname(@storage_path), 'deposits')
@@ -165,6 +166,7 @@ module Hestia
165
166
 
166
167
  content_hash = deposit[:content_hash]
167
168
  @deposited_skills.reject! { |d| d[:internal_key] == internal_key }
169
+ @attestations.reject! { |a| a[:internal_key] == internal_key }
168
170
  @exchange_counts.delete(internal_key)
169
171
  delete_content(internal_key)
170
172
  save_state
@@ -206,6 +208,16 @@ module Hestia
206
208
  }
207
209
  result[:version] = fm_fields[:version] if fm_fields[:version]
208
210
  result[:license] = fm_fields[:license] if fm_fields[:license]
211
+ skill_attestations = get_attestations(dep[:skill_id], owner_agent_id: dep[:agent_id],
212
+ content_hash: dep[:content_hash])
213
+ unless skill_attestations.empty?
214
+ result[:attestations] = skill_attestations.map do |a|
215
+ { attester_id: a[:attester_id], attester_name: a[:attester_name],
216
+ claim: a[:claim], evidence_hash: a[:evidence_hash], content_hash: a[:content_hash],
217
+ deposited_at: a[:deposited_at],
218
+ has_signature: !!a[:signature], signed_payload: a[:signed_payload], signature: a[:signature] }
219
+ end
220
+ end
209
221
  result
210
222
  end
211
223
 
@@ -288,6 +300,113 @@ module Hestia
288
300
  { removed: expired.size, remaining: @deposited_skills.size }
289
301
  end
290
302
 
303
+ # Deposit an attestation on a skill. Attester must be authenticated.
304
+ # Stores a copy of the attestation metadata + signature on the Place.
305
+ # The original proof stays with the attester.
306
+ #
307
+ # @param attester_id [String] Agent ID of the attester
308
+ # @param attester_name [String] Display name of the attester
309
+ # @param skill_id [String] ID of the attested skill
310
+ # @param owner_agent_id [String] Owner of the attested skill
311
+ # @param claim [String] Attestation claim (e.g., "reviewed", "used_in_production")
312
+ # @param evidence_hash [String, nil] SHA256 hash of evidence (full evidence stays with attester)
313
+ # @param signature [String, nil] RSA signature of the signed_payload
314
+ # @param signed_payload [String, nil] Canonical payload that was signed
315
+ # @return [Hash] Result
316
+ MAX_CLAIM_BYTES = 200
317
+ MAX_SIGNATURE_BYTES = 1024
318
+ MAX_SIGNED_PAYLOAD_BYTES = 1024
319
+
320
+ def deposit_attestation(attester_id:, attester_name: nil, skill_id:, owner_agent_id:,
321
+ claim:, evidence_hash: nil, signature: nil, signed_payload: nil,
322
+ public_key: nil)
323
+ @mutex.synchronize do
324
+ # Verify the skill exists
325
+ internal_key = "#{owner_agent_id}/#{skill_id}"
326
+ deposit = @deposited_skills.find { |d| d[:internal_key] == internal_key }
327
+ unless deposit
328
+ return { valid: false, error: 'skill_not_found', message: "No deposit found: #{skill_id} (owner: #{owner_agent_id})" }
329
+ end
330
+
331
+ # Rate limit (reuse deposit rate limiter)
332
+ unless check_deposit_rate(attester_id)
333
+ return { valid: false, errors: ["Rate limit exceeded (max #{deposit_rate_limit}/hour)"] }
334
+ end
335
+ record_deposit_timestamp(attester_id)
336
+
337
+ # Size limits
338
+ if claim && claim.bytesize > MAX_CLAIM_BYTES
339
+ return { valid: false, error: 'claim_too_large', message: "Claim exceeds #{MAX_CLAIM_BYTES} bytes" }
340
+ end
341
+ if signature && signature.bytesize > MAX_SIGNATURE_BYTES
342
+ return { valid: false, error: 'signature_too_large', message: "Signature exceeds #{MAX_SIGNATURE_BYTES} bytes" }
343
+ end
344
+ if signed_payload && signed_payload.bytesize > MAX_SIGNED_PAYLOAD_BYTES
345
+ return { valid: false, error: 'payload_too_large', message: "Signed payload exceeds #{MAX_SIGNED_PAYLOAD_BYTES} bytes" }
346
+ end
347
+
348
+ # Verify signature server-side if public key available
349
+ if signature && signed_payload && public_key
350
+ begin
351
+ crypto = ::MMP::Crypto.new(auto_generate: false)
352
+ unless crypto.verify_signature(signed_payload, signature, public_key)
353
+ return { valid: false, error: 'signature_invalid', message: 'Signature verification failed' }
354
+ end
355
+ rescue StandardError => e
356
+ return { valid: false, error: 'signature_error', message: "Signature verification error: #{e.message}" }
357
+ end
358
+ end
359
+
360
+ # Prevent duplicate attestation (same attester + same claim on same skill VERSION)
361
+ current_hash = deposit[:content_hash]
362
+ existing = @attestations.find do |a|
363
+ a[:attester_id] == attester_id && a[:skill_id] == skill_id &&
364
+ a[:owner_agent_id] == owner_agent_id && a[:claim] == claim &&
365
+ a[:content_hash] == current_hash
366
+ end
367
+ if existing
368
+ return { valid: false, error: 'duplicate', message: "Attestation already exists: #{claim} by #{attester_id} on this version" }
369
+ end
370
+
371
+ now = Time.now.utc.iso8601
372
+ attestation = {
373
+ attester_id: attester_id,
374
+ attester_name: attester_name,
375
+ skill_id: skill_id,
376
+ owner_agent_id: owner_agent_id,
377
+ internal_key: internal_key,
378
+ claim: claim,
379
+ evidence_hash: evidence_hash,
380
+ content_hash: deposit[:content_hash],
381
+ signature: signature,
382
+ signed_payload: signed_payload,
383
+ deposited_at: now
384
+ }
385
+
386
+ @attestations << attestation
387
+ save_state
388
+ end
389
+
390
+ { valid: true, status: 'attestation_deposited', claim: claim, skill_id: skill_id, deposited_at: Time.now.utc.iso8601 }
391
+ end
392
+
393
+ # Get attestations for a deposited skill.
394
+ # Only returns attestations matching the current content_hash (version-bound).
395
+ def get_attestations(skill_id, owner_agent_id: nil, content_hash: nil)
396
+ candidates = if owner_agent_id
397
+ internal_key = "#{owner_agent_id}/#{skill_id}"
398
+ @attestations.select { |a| a[:internal_key] == internal_key }
399
+ else
400
+ @attestations.select { |a| a[:skill_id] == skill_id }
401
+ end
402
+ # Filter to current version if content_hash provided
403
+ if content_hash
404
+ candidates.select { |a| a[:content_hash] == content_hash }
405
+ else
406
+ candidates
407
+ end
408
+ end
409
+
291
410
  # Deposit limits for publishing in /place/v1/info.
292
411
  # Note: deposit_rate_limit is process-scoped (resets on server restart).
293
412
  def deposit_limits
@@ -567,6 +686,7 @@ module Hestia
567
686
  deposited_skills: @deposited_skills.map { |d| d.except(:content) },
568
687
  exchange_counts: @exchange_counts,
569
688
  total_exchange_count: @total_exchange_count,
689
+ attestations: @attestations,
570
690
  updated_at: Time.now.utc.iso8601
571
691
  }
572
692
  temp = "#{@storage_path}.tmp"
@@ -582,6 +702,7 @@ module Hestia
582
702
  data = JSON.parse(File.read(@storage_path), symbolize_names: true)
583
703
  @exchange_counts = (data[:exchange_counts] || {}).transform_keys(&:to_s)
584
704
  @total_exchange_count = data[:total_exchange_count] || 0
705
+ @attestations = data[:attestations] || []
585
706
  (data[:deposited_skills] || []).each do |dep|
586
707
  ik = dep[:internal_key]&.to_s
587
708
  next unless ik
@@ -754,6 +875,15 @@ module Hestia
754
875
  entry[:input_output] = dep[:input_output] if dep[:input_output]
755
876
  entry[:version] = fm_fields[:version] if fm_fields[:version]
756
877
  entry[:license] = fm_fields[:license] if fm_fields[:license]
878
+ skill_attestations = get_attestations(dep[:skill_id], owner_agent_id: dep[:agent_id],
879
+ content_hash: dep[:content_hash])
880
+ unless skill_attestations.empty?
881
+ entry[:attestations] = skill_attestations.map do |a|
882
+ { attester_id: a[:attester_id], attester_name: a[:attester_name],
883
+ claim: a[:claim], evidence_hash: a[:evidence_hash], deposited_at: a[:deposited_at],
884
+ has_signature: !!a[:signature] }
885
+ end
886
+ end
757
887
  entries << entry
758
888
  end
759
889
 
@@ -110,6 +110,13 @@ module MMP
110
110
  get("/place/v1/agent_profile/#{URI.encode_www_form_component(agent_id)}")
111
111
  end
112
112
 
113
+ def attest_skill(skill_id:, owner_agent_id:, claim:, evidence_hash: nil, signature: nil, signed_payload: nil)
114
+ post('/place/v1/board/attest', {
115
+ skill_id: skill_id, owner_agent_id: owner_agent_id, claim: claim,
116
+ evidence_hash: evidence_hash, signature: signature, signed_payload: signed_payload
117
+ })
118
+ end
119
+
113
120
  def send_encrypted(to:, message:, message_type: 'message')
114
121
  return { error: 'Not connected' } unless @connected
115
122
  return { error: 'No keypair' } unless @crypto&.has_keypair?
@@ -18,6 +18,7 @@
18
18
  "KairosMcp::SkillSets::MMP::Tools::MeetingPreviewSkill",
19
19
  "KairosMcp::SkillSets::MMP::Tools::MeetingCheckFreshness",
20
20
  "KairosMcp::SkillSets::MMP::Tools::MeetingGetAgentProfile",
21
+ "KairosMcp::SkillSets::MMP::Tools::MeetingAttestSkill",
21
22
  "KairosMcp::SkillSets::MMP::Tools::MeetingFederate"
22
23
  ],
23
24
  "config_files": ["config/meeting.yml"],
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+
6
+ module KairosMcp
7
+ module SkillSets
8
+ module MMP
9
+ module Tools
10
+ class MeetingAttestSkill < KairosMcp::Tools::BaseTool
11
+ def name
12
+ 'meeting_attest_skill'
13
+ end
14
+
15
+ def description
16
+ 'Deposit an attestation (signed claim) on a skill at a connected Meeting Place. The attestation is a copy — the original proof stays in your local chain. Other agents can see your attestation in browse/preview and verify your signature via your public key.'
17
+ end
18
+
19
+ def category
20
+ :meeting
21
+ end
22
+
23
+ def usecase_tags
24
+ %w[meeting attestation trust claim verify skill]
25
+ end
26
+
27
+ def related_tools
28
+ %w[meeting_browse meeting_preview_skill attestation_issue meeting_get_agent_profile]
29
+ end
30
+
31
+ def input_schema
32
+ {
33
+ type: 'object',
34
+ properties: {
35
+ skill_id: {
36
+ type: 'string',
37
+ description: 'ID of the skill to attest'
38
+ },
39
+ owner_agent_id: {
40
+ type: 'string',
41
+ description: 'Agent ID of the skill owner (depositor)'
42
+ },
43
+ claim: {
44
+ type: 'string',
45
+ description: 'Attestation claim (e.g., "reviewed", "used_in_production", "philosophical_depth_verified")'
46
+ },
47
+ evidence: {
48
+ type: 'string',
49
+ description: 'Optional evidence text (hashed before sending — full text stays local)'
50
+ }
51
+ },
52
+ required: %w[skill_id owner_agent_id claim]
53
+ }
54
+ end
55
+
56
+ def call(arguments)
57
+ client = build_place_client
58
+ return client if client.is_a?(String)
59
+
60
+ skill_id = arguments['skill_id']
61
+ owner_agent_id = arguments['owner_agent_id']
62
+ claim = arguments['claim']
63
+ evidence = arguments['evidence']
64
+
65
+ begin
66
+ config = ::MMP.load_config
67
+ identity = ::MMP::Identity.new(config: config)
68
+ crypto = identity.crypto
69
+ attester_id = identity.instance_id
70
+
71
+ # Fetch current skill content_hash for version-bound attestation
72
+ preview = client.preview_skill(skill_id: skill_id, owner: owner_agent_id, first_lines: 1)
73
+ skill_content_hash = preview[:content_hash]
74
+
75
+ # Build signed payload: canonical form with content_hash for cryptographic version binding
76
+ timestamp = Time.now.utc.iso8601
77
+ evidence_hash = evidence ? Digest::SHA256.hexdigest(evidence) : nil
78
+ signed_payload = [attester_id, claim, skill_id, owner_agent_id, skill_content_hash, evidence_hash, timestamp].compact.join('|')
79
+ signature = crypto&.has_keypair? ? crypto.sign(signed_payload) : nil
80
+
81
+ result = client.attest_skill(
82
+ skill_id: skill_id,
83
+ owner_agent_id: owner_agent_id,
84
+ claim: claim,
85
+ evidence_hash: evidence_hash,
86
+ signature: signature,
87
+ signed_payload: signed_payload
88
+ )
89
+
90
+ if result[:valid] || result[:status] == 'attestation_deposited'
91
+ text_content(JSON.pretty_generate({
92
+ status: 'attestation_deposited',
93
+ skill_id: skill_id,
94
+ owner_agent_id: owner_agent_id,
95
+ claim: claim,
96
+ evidence_hash: evidence_hash,
97
+ signed: !!signature,
98
+ deposited_at: result[:deposited_at],
99
+ note: 'Attestation copy deposited to Meeting Place. Other agents can see this in browse/preview and verify your signature via your public key.'
100
+ }))
101
+ else
102
+ text_content(JSON.pretty_generate({
103
+ error: result[:error] || 'Attestation failed',
104
+ message: result[:message]
105
+ }.compact))
106
+ end
107
+ rescue StandardError => e
108
+ text_content(JSON.pretty_generate({ error: 'Attestation failed', message: e.message }))
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def build_place_client
115
+ config = ::MMP.load_config
116
+ unless config['enabled']
117
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
118
+ end
119
+
120
+ connection = load_connection_state
121
+ unless connection
122
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
123
+ end
124
+
125
+ url = connection['url'] || connection[:url]
126
+ token = connection['session_token'] || connection[:session_token]
127
+ identity = ::MMP::Identity.new(config: config)
128
+ client = ::MMP::PlaceClient.new(place_url: url, identity: identity, config: {})
129
+ client.instance_variable_set(:@bearer_token, token)
130
+ client.instance_variable_set(:@connected, true)
131
+ client
132
+ end
133
+
134
+ def load_connection_state
135
+ f = File.join(KairosMcp.storage_dir, 'meeting_connection.json')
136
+ File.exist?(f) ? JSON.parse(File.read(f)) : nil
137
+ rescue StandardError; nil
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.1
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -284,6 +284,7 @@ files:
284
284
  - templates/skillsets/mmp/lib/mmp/skill_exchange.rb
285
285
  - templates/skillsets/mmp/skillset.json
286
286
  - templates/skillsets/mmp/tools/meeting_acquire_skill.rb
287
+ - templates/skillsets/mmp/tools/meeting_attest_skill.rb
287
288
  - templates/skillsets/mmp/tools/meeting_browse.rb
288
289
  - templates/skillsets/mmp/tools/meeting_check_freshness.rb
289
290
  - templates/skillsets/mmp/tools/meeting_connect.rb