kairos-chain 3.39.0 → 3.40.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: 0da61a60c3ecd5e32e85052668638f65522376949dc5577a54ab5db754e7118a
4
- data.tar.gz: fe8f12657d7032c9047368372dd6e49bc733e83804a55c83717608980d0017bc
3
+ metadata.gz: 2285b28e2f2954ca10f6da869b7446b4d18fdc0f9174451f20ec8f523142b322
4
+ data.tar.gz: 9a14c47fd141de6ff7eaec10d6e56c44f01e96b1cb0b9ab962eedb4baccc8e4b
5
5
  SHA512:
6
- metadata.gz: 6e466478fd4e87509d7b4efa1218813fba0f395ea2296852a69b4f2190070ddddf59b2a8961f4b1a34d71ad4d1666716df105ad609f21e6ac9f91e228a58dbf8
7
- data.tar.gz: 4cf41c84ff411cb3e18de05683343eb3f84a3281c7f93461ec90c6ea4806919ebf58ae70694e0e629248189411b6b815d764cb3e10f6956d502f5222fc60cc46
6
+ metadata.gz: 89a5a8a47b38f8f703283c58d9e402efeab1a2124fa1ad2195c0820edd985301ce68b22dd40012614499d09f9b34410905e473ca33ccad97e8d32d804ba46eb1
7
+ data.tar.gz: df15f0d5f6cdb22039840cf780b235cba8194e77da2738478b37dbeb446c25814609887083566388bae2abeb2f067e0aa2ecb24d16b4ca626b75644818d794eb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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.40.0] - 2026-07-06
8
+
9
+ ### Added — Optional built-in TLS/HTTPS for the HTTP transport
10
+
11
+ Opt-in TLS termination for the Puma-based MCP HTTP server, so a self-hosted
12
+ instance can be reached securely over the network without a reverse proxy.
13
+ Encryption is delegated to Puma/OpenSSL (Prop 2: transport is execution
14
+ substrate); KairosChain only selects the bind scheme and can generate a
15
+ self-signed certificate for single-operator use. Default remains plain HTTP
16
+ (backward compatible).
17
+
18
+ - **`TlsConfig`** — `tcp://` vs `ssl://` bind selection, fail-closed validation
19
+ (exists + readable + valid PEM + cert/key pair match), IPv6 host bracketing,
20
+ certificate-expiry reporting.
21
+ - **`TlsCertGenerator`** — self-signed leaf certificate via the OpenSSL stdlib:
22
+ `CA:FALSE`, `keyUsage` + `extendedKeyUsage=serverAuth`, SAN built from a host
23
+ list (loopback + system hostname + config host + `--cert-host`), positive
24
+ serial, atomic `0600` private-key write.
25
+ - **`HttpServer`** — `--tls` bind, startup certificate-expiry warning, and
26
+ fail-closed refusal to start an unauthenticated endpoint on a network-reachable
27
+ bind (override with `KAIROS_ALLOW_OPEN_ENDPOINT=1`).
28
+ - **CLI** — `--tls`, `--gen-cert`, `--cert-host`; `--init-admin` sample URL
29
+ scheme resolved from config + flag.
30
+ - **Config** — `http.tls` block (default disabled).
31
+ - **Docs** — `kairoschain_setup` knowledge (en/jp) documents built-in TLS
32
+ (Option A) alongside the reverse-proxy path (Option B).
33
+
34
+ Reviewed via a 2-round multi-LLM review (R1 raised 3 P1 + P2s; R2 verified all
35
+ resolved). 36/36 HTTP tests pass; verified end-to-end with a real
36
+ certificate-verified HTTPS handshake and the fail-closed guard matrix.
37
+
7
38
  ## [3.39.0] - 2026-07-05
8
39
 
9
40
  ### Added — L1 `multi_llm_review_workflow` v3.5 → v3.6: Step 0.25 Unknowns Pass
data/bin/kairos-chain CHANGED
@@ -636,10 +636,22 @@ OptionParser.new do |opts|
636
636
  options[:port] = port
637
637
  end
638
638
 
639
- opts.on('--host HOST', 'HTTP bind host (default: 0.0.0.0)') do |host|
639
+ opts.on('--host HOST', 'HTTP bind host (default: config http.host, else 127.0.0.1)') do |host|
640
640
  options[:host] = host
641
641
  end
642
642
 
643
+ opts.on('--tls', 'Enable TLS (HTTPS) for --http mode (overrides config)') do
644
+ options[:tls] = true
645
+ end
646
+
647
+ opts.on('--gen-cert', 'Generate a self-signed TLS cert/key for --tls and exit') do
648
+ options[:gen_cert] = true
649
+ end
650
+
651
+ opts.on('--cert-host HOST', 'Add a hostname/IP to the generated cert SAN (repeatable)') do |h|
652
+ (options[:cert_hosts] ||= []) << h
653
+ end
654
+
643
655
  opts.on('--init-admin', 'Generate initial admin token and exit') do
644
656
  options[:init_admin] = true
645
657
  end
@@ -715,6 +727,72 @@ if options[:version]
715
727
  exit
716
728
  end
717
729
 
730
+ # Handle --gen-cert (generate self-signed TLS certificate and exit)
731
+ if options[:gen_cert]
732
+ require 'kairos_mcp/tls_config'
733
+ require 'kairos_mcp/tls_cert_generator'
734
+ require 'kairos_mcp/skills_config'
735
+
736
+ http_config = KairosMcp::SkillsConfig.load['http'] || {}
737
+ tls = KairosMcp::TlsConfig.new(http_config, data_dir: KairosMcp.data_dir, force_enabled: true)
738
+
739
+ cert_exists = File.exist?(tls.cert_path) || File.exist?(tls.key_path)
740
+ if cert_exists
741
+ $stderr.puts "[gen-cert] A cert/key already exists:"
742
+ $stderr.puts " cert: #{tls.cert_path}" if File.exist?(tls.cert_path)
743
+ $stderr.puts " key: #{tls.key_path}" if File.exist?(tls.key_path)
744
+ if KairosMcp::TlsCertGenerator.overwrite_refused_noninteractive?(exists: cert_exists, tty: $stdin.tty?)
745
+ $stderr.puts "[gen-cert] Non-interactive mode: refusing to overwrite. Delete the files to regenerate."
746
+ exit 1
747
+ else
748
+ $stderr.puts ""
749
+ $stderr.puts "Overwrite? (y/N)"
750
+ answer = $stdin.gets&.strip
751
+ exit unless answer&.downcase == 'y'
752
+ end
753
+ end
754
+
755
+ # Build the SAN host list: loopback defaults + system hostname + config host
756
+ # + any --cert-host entries. Without this the cert only covers localhost and
757
+ # remote clients connecting by hostname/IP fail certificate verification.
758
+ require 'socket'
759
+ san_hosts = KairosMcp::TlsCertGenerator::DEFAULT_HOSTS.dup
760
+ begin
761
+ san_hosts << Socket.gethostname
762
+ rescue StandardError
763
+ # hostname is best-effort; loopback defaults still apply
764
+ end
765
+ cfg_host = http_config['host']
766
+ san_hosts << cfg_host unless cfg_host.nil? || ['0.0.0.0', '::', ''].include?(cfg_host)
767
+ san_hosts.concat(Array(options[:cert_hosts]))
768
+
769
+ result = KairosMcp::TlsCertGenerator.generate(
770
+ cert_path: tls.cert_path, key_path: tls.key_path, hosts: san_hosts
771
+ )
772
+
773
+ $stderr.puts ""
774
+ $stderr.puts "=" * 60
775
+ $stderr.puts " Self-signed TLS certificate generated"
776
+ $stderr.puts "=" * 60
777
+ $stderr.puts " cert: #{result[:cert_path]}"
778
+ $stderr.puts " key: #{result[:key_path]} (chmod 0600)"
779
+ $stderr.puts " expires: #{result[:not_after]}"
780
+ $stderr.puts " SAN: #{result[:san]}"
781
+ $stderr.puts ""
782
+ $stderr.puts " If you access this server by a hostname/IP not listed in SAN,"
783
+ $stderr.puts " regenerate with: kairos-chain --gen-cert --cert-host <name>"
784
+ $stderr.puts ""
785
+ $stderr.puts " Enable TLS by either:"
786
+ $stderr.puts " - starting with: kairos-chain --http --tls"
787
+ $stderr.puts " - or setting http.tls.enabled: true in skills/config.yml"
788
+ $stderr.puts ""
789
+ $stderr.puts " NOTE: self-signed certs are for single-operator use. Clients"
790
+ $stderr.puts " must trust this cert (or disable verification). For a public"
791
+ $stderr.puts " service use a CA-issued cert behind a reverse proxy."
792
+ $stderr.puts "=" * 60
793
+ exit
794
+ end
795
+
718
796
  # Handle --init-admin
719
797
  if options[:init_admin]
720
798
  require 'kairos_mcp/auth/token_store'
@@ -734,6 +812,13 @@ if options[:init_admin]
734
812
  store_path = File.join(KairosMcp.data_dir, store_path)
735
813
  end
736
814
 
815
+ # Resolve the actual scheme from TlsConfig (config OR --tls), not from the
816
+ # --tls flag alone — otherwise config-enabled TLS prints an http:// URL.
817
+ require 'kairos_mcp/tls_config'
818
+ admin_scheme = KairosMcp::TlsConfig.new(
819
+ http_config, data_dir: KairosMcp.data_dir, force_enabled: options[:tls]
820
+ ).scheme
821
+
737
822
  store = KairosMcp::Auth::TokenStore.create(
738
823
  backend: http_config['token_backend'],
739
824
  store_path: store_path
@@ -790,9 +875,9 @@ if options[:init_admin]
790
875
  $stderr.puts " {"
791
876
  $stderr.puts " \"mcpServers\": {"
792
877
  $stderr.puts " \"kairos\": {"
793
- $stderr.puts " \"url\": \"http://localhost:#{options[:port] || 8080}/mcp\","
878
+ $stderr.puts " \"url\": \"#{admin_scheme}://localhost:#{options[:port] || 8080}/mcp\","
794
879
  $stderr.puts " \"headers\": {"
795
- $stderr.puts " \"Authorization\": \"Bearer #{result['raw_token']}\""
880
+ $stderr.puts " \"Authorization\": \"Bearer #{result[:raw_token] || result['raw_token']}\""
796
881
  $stderr.puts " }"
797
882
  $stderr.puts " }"
798
883
  $stderr.puts " }"
@@ -817,7 +902,8 @@ if options[:http]
817
902
  server = KairosMcp::HttpServer.new(
818
903
  port: options[:port],
819
904
  host: options[:host],
820
- token_store_path: options[:token_store]
905
+ token_store_path: options[:token_store],
906
+ tls: options[:tls]
821
907
  )
822
908
  server.run
823
909
  else
@@ -9,6 +9,7 @@ require_relative '../kairos_mcp'
9
9
  require_relative 'auth/token_store'
10
10
  require_relative 'auth/authenticator'
11
11
  require_relative 'skills_config'
12
+ require_relative 'tls_config'
12
13
  require_relative 'admin/router'
13
14
  require_relative 'meeting_router'
14
15
 
@@ -39,14 +40,19 @@ module KairosMcp
39
40
  'Cache-Control' => 'no-cache'
40
41
  }.freeze
41
42
 
42
- attr_reader :port, :host, :token_store, :authenticator, :admin_router, :meeting_router, :place_router
43
+ attr_reader :port, :host, :token_store, :authenticator, :admin_router,
44
+ :meeting_router, :place_router, :tls
43
45
 
44
- def initialize(port: nil, host: nil, token_store_path: nil)
46
+ def initialize(port: nil, host: nil, token_store_path: nil, tls: nil)
45
47
  http_config = SkillsConfig.load['http'] || {}
46
48
 
47
49
  @port = port || http_config['port'] || DEFAULT_PORT
48
50
  @host = host || http_config['host'] || DEFAULT_HOST
49
51
 
52
+ # TLS is opt-in and delegated to Puma/OpenSSL (Prop 2: execution
53
+ # substrate). `tls: true` from the CLI (--tls) overrides the config.
54
+ @tls = TlsConfig.new(http_config, data_dir: KairosMcp.data_dir, force_enabled: tls)
55
+
50
56
  # SkillSets must load BEFORE TokenStore.create so that plugins
51
57
  # (e.g. Multiuser) can register alternative backends first.
52
58
  eager_load_skillsets
@@ -79,6 +85,7 @@ module KairosMcp
79
85
  def run
80
86
  KairosMcp.http_server = self
81
87
  check_dependencies!
88
+ check_tls!
82
89
  check_tokens!
83
90
  check_version_mismatch
84
91
  auto_start_meeting_place
@@ -87,7 +94,9 @@ module KairosMcp
87
94
  server = self
88
95
 
89
96
  log "Starting KairosChain MCP Server v#{VERSION} (Streamable HTTP)"
90
- log "Listening on #{@host}:#{@port}"
97
+ log "Listening on #{@tls.scheme}://#{@host}:#{@port}"
98
+ log "TLS: #{@tls.enabled? ? "enabled (cert: #{@tls.cert_path})" : 'disabled (plain HTTP)'}"
99
+ log_tls_expiry
91
100
  log "MCP endpoint: POST /mcp"
92
101
  log "Health check: GET /health"
93
102
  log "Admin UI: GET /admin"
@@ -99,7 +108,7 @@ module KairosMcp
99
108
  require 'puma/launcher'
100
109
 
101
110
  puma_config = Puma::Configuration.new do |config|
102
- config.bind "tcp://#{server.host}:#{server.port}"
111
+ config.bind server.bind_uri
103
112
  config.app app
104
113
  config.workers 0
105
114
  config.threads 1, 5
@@ -176,6 +185,7 @@ module KairosMcp
176
185
  server: 'kairos-chain',
177
186
  version: KairosMcp::VERSION,
178
187
  transport: 'streamable-http',
188
+ tls: @tls.enabled?,
179
189
  tokens_configured: !@token_store.empty?,
180
190
  place_started: !@place_router.nil?
181
191
  }
@@ -296,8 +306,63 @@ module KairosMcp
296
306
  [status, JSON_HEADERS, [body_hash.to_json]]
297
307
  end
298
308
 
309
+ # Puma bind URI for the configured host/port. Scheme (tcp:// vs ssl://)
310
+ # is decided by TlsConfig. Public for testability.
311
+ def bind_uri
312
+ @tls.bind_uri(@host, @port)
313
+ end
314
+
299
315
  private
300
316
 
317
+ # Fail-closed TLS check: if TLS is enabled but cert/key are missing,
318
+ # abort with actionable guidance rather than starting plain HTTP.
319
+ def check_tls!
320
+ @tls.validate!
321
+ rescue TlsConfigError => e
322
+ $stderr.puts "[ERROR] #{e.message}"
323
+ exit 1
324
+ end
325
+
326
+ # Surface certificate expiry so a self-signed cert does not silently stop
327
+ # working after its validity window (no auto-renewal for --gen-cert).
328
+ EXPIRY_WARN_DAYS = 30
329
+
330
+ def log_tls_expiry
331
+ return unless @tls.enabled?
332
+
333
+ days = @tls.days_until_expiry
334
+ return if days.nil?
335
+
336
+ not_after = @tls.certificate_not_after
337
+ if days.negative?
338
+ $stderr.puts "[WARN] TLS certificate EXPIRED #{-days} day(s) ago (#{not_after}). " \
339
+ "Regenerate with 'kairos-chain --gen-cert' (delete the old cert first)."
340
+ else
341
+ log "TLS cert expires: #{not_after} (#{days} days)"
342
+ if days <= EXPIRY_WARN_DAYS
343
+ $stderr.puts "[WARN] TLS certificate expires in #{days} day(s). " \
344
+ "Regenerate with 'kairos-chain --gen-cert' (delete the old cert first)."
345
+ end
346
+ end
347
+ end
348
+
349
+ LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'].freeze
350
+
351
+ # Environment opt-out for the fail-closed open-endpoint guard.
352
+ ALLOW_OPEN_ENDPOINT_ENV = 'KAIROS_ALLOW_OPEN_ENDPOINT'
353
+
354
+ def loopback_only?
355
+ LOOPBACK_HOSTS.include?(@host)
356
+ end
357
+
358
+ # Pure predicate (class method for testability): an empty token store means
359
+ # unauthenticated owner access. That is only acceptable on a loopback bind
360
+ # without TLS (local dev). Network-reachable (non-loopback) or TLS-enabled
361
+ # (remote intent) + empty store = an open owner endpoint.
362
+ def self.exposed_without_auth?(token_store_empty:, loopback:, tls_enabled:)
363
+ token_store_empty && (!loopback || tls_enabled)
364
+ end
365
+
301
366
  # Register place extensions from enabled SkillSets that declare place_extensions.
302
367
  # Uses KairosMcp.http_server pattern for late registration access.
303
368
  def register_place_extensions(router)
@@ -367,13 +432,49 @@ module KairosMcp
367
432
  end
368
433
 
369
434
  def check_tokens!
370
- if @token_store.empty?
435
+ return unless @token_store.empty?
436
+
437
+ # An empty token store means the MCP endpoint accepts unauthenticated
438
+ # owner-level requests. On loopback without TLS that is convenient
439
+ # local-dev. Exposed over the network (non-loopback, or TLS = remote
440
+ # intent) it is an OPEN OWNER ENDPOINT — encrypting an open door.
441
+ exposed = self.class.exposed_without_auth?(
442
+ token_store_empty: true, loopback: loopback_only?, tls_enabled: @tls.enabled?
443
+ )
444
+
445
+ unless exposed
371
446
  $stderr.puts <<~MSG
372
447
  [INFO] Local dev mode: no tokens configured.
373
448
  MCP endpoint accepts unauthenticated requests as local owner.
374
449
  For production, generate a token with: kairos-chain --init-admin
375
450
 
376
451
  MSG
452
+ return
453
+ end
454
+
455
+ # Fail-closed: refuse to start an open owner endpoint on a reachable
456
+ # bind. The operator can opt out explicitly for intentional cases.
457
+ if ENV[ALLOW_OPEN_ENDPOINT_ENV] == '1'
458
+ $stderr.puts <<~MSG
459
+ [SECURITY] No tokens configured and the endpoint is network-reachable
460
+ (host=#{@host}#{@tls.enabled? ? ', TLS enabled' : ''}). Starting anyway
461
+ because #{ALLOW_OPEN_ENDPOINT_ENV}=1. Unauthenticated requests are
462
+ accepted as owner — anyone who can reach this port has full owner access.
463
+
464
+ MSG
465
+ else
466
+ $stderr.puts <<~MSG
467
+ [ERROR] Refusing to start: no tokens configured but the endpoint is
468
+ network-reachable (host=#{@host}#{@tls.enabled? ? ', TLS enabled' : ''}).
469
+ Unauthenticated requests would be accepted as owner — TLS encrypts the
470
+ traffic but does NOT authenticate it, so this is an open owner endpoint.
471
+
472
+ Generate an admin token first: kairos-chain --init-admin
473
+ Or bind to loopback (host 127.0.0.1) for local-only use.
474
+ To start intentionally without auth, set #{ALLOW_OPEN_ENDPOINT_ENV}=1.
475
+
476
+ MSG
477
+ exit 1
377
478
  end
378
479
  end
379
480
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'fileutils'
6
+ require 'ipaddr'
7
+
8
+ module KairosMcp
9
+ # TlsCertGenerator: produce a self-signed certificate + key for the
10
+ # single-operator remote-access case.
11
+ #
12
+ # Design intent (Prop 2): this uses Ruby's OpenSSL stdlib to *generate* key
13
+ # material — it does not implement any cryptography of its own. It exists so
14
+ # that "kairos-chain --gen-cert" gives a one-shot path to HTTPS for a single
15
+ # operator. For a public, multi-user service, use a CA-issued certificate
16
+ # (e.g. behind a reverse proxy) instead of a self-signed one.
17
+ module TlsCertGenerator
18
+ module_function
19
+
20
+ # Hostnames/IPs always covered by the generated certificate's SAN so that
21
+ # loopback access verifies without --cert-host. Remote names are added by
22
+ # the caller (system hostname, config host, --cert-host entries).
23
+ DEFAULT_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
24
+
25
+ # @param hosts [Array<String>] hostnames/IPs to place in the SAN. Each entry
26
+ # is auto-classified as IP: or DNS:. The common_name is always included.
27
+ # @return [Hash] { cert_path:, key_path:, not_after:, san: }
28
+ def generate(cert_path:, key_path:, common_name: 'kairos-chain',
29
+ hosts: DEFAULT_HOSTS, days: 825, key_size: 2048)
30
+ key = OpenSSL::PKey::RSA.new(key_size)
31
+
32
+ name = OpenSSL::X509::Name.parse("/CN=#{common_name}")
33
+ cert = OpenSSL::X509::Certificate.new
34
+ cert.version = 2
35
+ # Positive, non-zero serial. RFC 5280 requires a positive integer;
36
+ # SecureRandom.random_number(1 << 64) can return 0, so add 1.
37
+ cert.serial = OpenSSL::BN.new(SecureRandom.random_number(1 << 64) + 1)
38
+ cert.subject = name
39
+ cert.issuer = name
40
+ cert.public_key = key.public_key
41
+
42
+ now = Time.now
43
+ cert.not_before = now - 3600 # tolerate minor clock skew
44
+ cert.not_after = now + (days * 24 * 3600)
45
+
46
+ san = san_value(common_name, hosts)
47
+
48
+ ef = OpenSSL::X509::ExtensionFactory.new
49
+ ef.subject_certificate = cert
50
+ ef.issuer_certificate = cert
51
+ # Leaf SERVER certificate — explicitly NOT a CA, scoped to serverAuth so
52
+ # strict clients (macOS Secure Transport, Chromium) accept it.
53
+ cert.add_extension(ef.create_extension('basicConstraints', 'CA:FALSE', true))
54
+ cert.add_extension(ef.create_extension('keyUsage', 'digitalSignature,keyEncipherment', true))
55
+ cert.add_extension(ef.create_extension('extendedKeyUsage', 'serverAuth', false))
56
+ cert.add_extension(ef.create_extension('subjectAltName', san, false))
57
+ cert.sign(key, OpenSSL::Digest.new('SHA256'))
58
+
59
+ write_secure(cert_path, cert.to_pem, 0o644)
60
+ write_secure(key_path, key.to_pem, 0o600)
61
+
62
+ { cert_path: cert_path, key_path: key_path, not_after: cert.not_after, san: san }
63
+ end
64
+
65
+ # Build a subjectAltName value from a host list, auto-classifying each
66
+ # entry as IP: or DNS:. The common_name is always included as DNS.
67
+ def san_value(common_name, hosts)
68
+ entries = []
69
+ add_san(entries, common_name)
70
+ Array(hosts).each { |h| add_san(entries, h) }
71
+ entries.uniq.join(',')
72
+ end
73
+
74
+ def add_san(entries, host)
75
+ return if host.nil? || host.to_s.strip.empty?
76
+
77
+ h = host.to_s.strip
78
+ # Reject injection: a raw entry must be a single hostname/IP, not a
79
+ # comma-separated list or a pre-formatted "DNS:/IP:" token — otherwise
80
+ # --cert-host "a,DNS:evil" would silently mint extra SAN names.
81
+ if h.include?(',') || h =~ /\A(DNS|IP):/i
82
+ raise ArgumentError, "invalid SAN host #{h.inspect}: must be a single hostname or IP"
83
+ end
84
+
85
+ begin
86
+ IPAddr.new(h)
87
+ entries << "IP:#{h}"
88
+ rescue IPAddr::InvalidAddressError, ArgumentError
89
+ entries << "DNS:#{h}"
90
+ end
91
+ end
92
+
93
+ # Deterministic half of the --gen-cert overwrite guard: in non-interactive
94
+ # mode, refuse to clobber an existing cert/key (which could break a live
95
+ # deployment). Interactive mode prompts the operator instead. Extracted so
96
+ # the refusal branch is unit-testable.
97
+ def overwrite_refused_noninteractive?(exists:, tty:)
98
+ exists && !tty
99
+ end
100
+
101
+ def write_secure(path, content, mode)
102
+ FileUtils.mkdir_p(File.dirname(path))
103
+ # Open with target mode (new files) AND chmod the descriptor BEFORE
104
+ # writing, so on OVERWRITE of a pre-existing world-readable file the key
105
+ # bytes are never written while the old permissive mode is still in
106
+ # effect. Closes the umask/overwrite window on both create and overwrite.
107
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, mode) do |f|
108
+ f.chmod(mode)
109
+ f.write(content)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'openssl'
5
+
6
+ module KairosMcp
7
+ # TlsConfig: resolves optional TLS settings for the HTTP transport.
8
+ #
9
+ # Design intent (Prop 2, partial autopoiesis): transport encryption is an
10
+ # execution-substrate concern, not a self-referential core capability.
11
+ # KairosChain does NOT implement crypto here. It delegates to Puma/OpenSSL
12
+ # and only decides the bind scheme (tcp:// vs ssl://) from config. The only
13
+ # logic in this class is path resolution and fail-closed validation.
14
+ #
15
+ # Config shape (under the 'http' key of skills/config.yml):
16
+ # http:
17
+ # tls:
18
+ # enabled: false
19
+ # cert: "storage/tls/cert.pem"
20
+ # key: "storage/tls/key.pem"
21
+ #
22
+ class TlsConfig
23
+ DEFAULT_CERT = 'storage/tls/cert.pem'
24
+ DEFAULT_KEY = 'storage/tls/key.pem'
25
+
26
+ attr_reader :cert_path, :key_path
27
+
28
+ # @param http_config [Hash] the 'http' section of the loaded config
29
+ # @param data_dir [String] base dir for resolving relative cert/key paths
30
+ # @param force_enabled [Boolean, nil] CLI override; nil = use config value
31
+ def initialize(http_config, data_dir:, force_enabled: nil)
32
+ tls = (http_config || {})['tls'] || {}
33
+ @data_dir = data_dir
34
+ @enabled = force_enabled.nil? ? (tls['enabled'] == true) : force_enabled
35
+ # An empty-string path in config falls back to the default rather than
36
+ # resolving to nil (which would crash the --gen-cert path).
37
+ @cert_path = resolve(present_or(tls['cert'], DEFAULT_CERT))
38
+ @key_path = resolve(present_or(tls['key'], DEFAULT_KEY))
39
+ end
40
+
41
+ def enabled?
42
+ @enabled
43
+ end
44
+
45
+ # Fail-closed validation. When TLS is enabled, the cert and key must exist,
46
+ # be readable, and parse as valid material — otherwise abort rather than
47
+ # start plain HTTP or crash later inside Puma with an opaque error.
48
+ def validate!
49
+ return unless @enabled
50
+
51
+ problems = []
52
+ problems << "certificate is missing (#{@cert_path})" unless @cert_path && File.exist?(@cert_path)
53
+ problems << "private key is missing (#{@key_path})" unless @key_path && File.exist?(@key_path)
54
+
55
+ if problems.empty?
56
+ problems << "certificate is not readable (#{@cert_path})" unless File.readable?(@cert_path)
57
+ problems << "private key is not readable (#{@key_path})" unless File.readable?(@key_path)
58
+ end
59
+
60
+ if problems.empty?
61
+ cert = safe_parse { OpenSSL::X509::Certificate.new(File.read(@cert_path)) }
62
+ key = safe_parse { OpenSSL::PKey.read(File.read(@key_path)) }
63
+ problems << "certificate is not valid PEM (#{@cert_path})" if cert.nil?
64
+ problems << "private key is not valid (#{@key_path})" if key.nil?
65
+ # A parseable cert + parseable key that do not form a pair would pass
66
+ # independent checks but fail opaquely inside Puma's SSL setup.
67
+ if cert && key && !cert.check_private_key(key)
68
+ problems << "certificate and private key do not match (#{@cert_path} / #{@key_path})"
69
+ end
70
+ end
71
+
72
+ return if problems.empty?
73
+
74
+ raise TlsConfigError, <<~MSG.strip
75
+ TLS is enabled but its material is unusable:
76
+ - #{problems.join("\n - ")}
77
+
78
+ Generate a self-signed certificate for single-operator use:
79
+ kairos-chain --gen-cert
80
+
81
+ Or point http.tls.cert / http.tls.key at a valid certificate.
82
+ MSG
83
+ end
84
+
85
+ # Build a Puma bind URI. Encryption params are passed to Puma, which
86
+ # performs the TLS handshake via OpenSSL.
87
+ def bind_uri(host, port)
88
+ h = bracket_ipv6(host)
89
+ return "tcp://#{h}:#{port}" unless @enabled
90
+
91
+ query = URI.encode_www_form('key' => @key_path, 'cert' => @cert_path)
92
+ "ssl://#{h}:#{port}?#{query}"
93
+ end
94
+
95
+ def scheme
96
+ @enabled ? 'https' : 'http'
97
+ end
98
+
99
+ # Expiry (not_after) of the configured certificate, or nil if it cannot be
100
+ # read/parsed. Used to surface silent-expiry breakage at startup.
101
+ def certificate_not_after
102
+ return nil unless @cert_path && File.exist?(@cert_path)
103
+
104
+ OpenSSL::X509::Certificate.new(File.read(@cert_path)).not_after
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ # Whole days until the certificate expires (negative if already expired),
110
+ # or nil if the cert cannot be read. now is injectable for testing.
111
+ def days_until_expiry(now = Time.now)
112
+ not_after = certificate_not_after
113
+ return nil unless not_after
114
+
115
+ ((not_after - now) / 86_400).floor
116
+ end
117
+
118
+ private
119
+
120
+ # An IPv6 literal host must be bracketed in a URI authority
121
+ # (ssl://[::1]:8443), otherwise Puma's binder misparses the colons.
122
+ def bracket_ipv6(host)
123
+ s = host.to_s
124
+ return s if s.empty? || s.start_with?('[') || !s.include?(':')
125
+
126
+ "[#{s}]"
127
+ end
128
+
129
+ def present_or(value, default)
130
+ s = value.to_s.strip
131
+ s.empty? ? default : s
132
+ end
133
+
134
+ def resolve(path)
135
+ return nil if path.nil? || path.to_s.empty?
136
+
137
+ File.absolute_path?(path) ? path : File.join(@data_dir, path)
138
+ end
139
+
140
+ def safe_parse
141
+ yield
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ end
146
+
147
+ # Raised when TLS is enabled but its material cannot be located or used.
148
+ class TlsConfigError < StandardError; end
149
+ end
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.39.0"
2
+ VERSION = "3.40.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -654,7 +654,10 @@ Options:
654
654
  --data-dir DIR Data directory path (default: .kairos/ in current dir)
655
655
  --http Start in Streamable HTTP mode (default: stdio)
656
656
  --port PORT HTTP port (default: 8080)
657
- --host HOST HTTP bind host (default: 0.0.0.0)
657
+ --host HOST HTTP bind host (default: config http.host, else 127.0.0.1)
658
+ --tls Enable built-in TLS/HTTPS (requires a cert — see --gen-cert)
659
+ --gen-cert Generate a self-signed TLS cert/key and exit
660
+ --cert-host HOST Add a hostname/IP to the generated cert SAN (repeatable)
658
661
  --init-admin Generate initial admin token and exit
659
662
  --token-store PATH Path to token store file
660
663
  -v, --version Show version
@@ -662,11 +665,41 @@ Options:
662
665
 
663
666
  Environment Variables:
664
667
  KAIROS_DATA_DIR Override data directory path
668
+ KAIROS_ALLOW_OPEN_ENDPOINT Set to 1 to start without a token on a network-reachable bind (unsafe)
665
669
  ```
666
670
 
667
- #### Production Deployment with HTTPS
671
+ #### HTTPS / TLS
668
672
 
669
- For production use, place a reverse proxy in front of Puma to handle TLS/HTTPS. Puma only handles plain HTTP internally; the reverse proxy terminates SSL.
673
+ There are two ways to serve the MCP endpoint over TLS. Encryption is always performed by
674
+ Puma/OpenSSL — KairosChain only selects the transport (plain HTTP vs TLS).
675
+
676
+ **Option A: Built-in TLS (single operator / remote access)**
677
+
678
+ KairosChain can terminate TLS itself, so a self-hosted instance can be reached securely
679
+ without a separate proxy.
680
+
681
+ ```bash
682
+ # 1. Generate a self-signed cert/key once (written to <data-dir>/storage/tls/).
683
+ # Include every hostname/IP you connect by, so certificate verification passes:
684
+ kairos-chain --gen-cert --cert-host myhost.example.com
685
+
686
+ # 2. Start with TLS (or set http.tls.enabled: true in skills/config.yml):
687
+ kairos-chain --http --tls
688
+ ```
689
+
690
+ Notes:
691
+ - The certificate is self-signed, so the client (Cursor / Claude Code) must trust it
692
+ (or disable verification). For a CA-issued certificate, use Option B.
693
+ - Certificates expire (default ~2 years). The server logs the expiry at startup and
694
+ warns when it is near; regenerate by deleting the old cert and running `--gen-cert` again.
695
+ - If no admin token is configured, KairosChain refuses to start on a network-reachable
696
+ bind (that would be an open owner endpoint). Run `--init-admin` first, or set
697
+ `KAIROS_ALLOW_OPEN_ENDPOINT=1` to start without authentication intentionally.
698
+
699
+ **Option B: Reverse proxy (public / multi-user)**
700
+
701
+ For a public or multi-user deployment, place a reverse proxy in front of Puma; the proxy
702
+ terminates TLS with a CA-issued certificate.
670
703
 
671
704
  ```
672
705
  Client (Cursor) ──HTTPS──▶ Reverse Proxy ──HTTP──▶ Puma (:8080)
@@ -674,7 +707,7 @@ Client (Cursor) ──HTTPS──▶ Reverse Proxy ──HTTP──▶ Puma (:80
674
707
  TLS termination
675
708
  ```
676
709
 
677
- **Option A: Caddy (Recommended — Simplest)**
710
+ **Caddy (Recommended — Simplest)**
678
711
 
679
712
  Caddy provides automatic HTTPS with Let's Encrypt certificates (zero configuration for TLS).
680
713
 
@@ -638,7 +638,10 @@ Usage: kairos-chain [command] [options]
638
638
  --data-dir DIR データディレクトリのパス(デフォルト: カレントディレクトリの .kairos/)
639
639
  --http Streamable HTTPモードで起動(デフォルト: stdio)
640
640
  --port PORT HTTPポート(デフォルト: 8080)
641
- --host HOST HTTPバインドホスト(デフォルト: 0.0.0.0
641
+ --host HOST HTTPバインドホスト(デフォルト: config の http.host、無ければ 127.0.0.1
642
+ --tls 内蔵TLS/HTTPSを有効化(証明書が必要 — --gen-cert を参照)
643
+ --gen-cert 自己署名のTLS証明書/鍵を生成して終了
644
+ --cert-host HOST 生成する証明書のSANにホスト名/IPを追加(繰り返し指定可)
642
645
  --init-admin 初期管理者トークンを生成して終了
643
646
  --token-store PATH トークンストアファイルのパス
644
647
  -v, --version バージョン表示
@@ -646,11 +649,34 @@ Usage: kairos-chain [command] [options]
646
649
 
647
650
  環境変数:
648
651
  KAIROS_DATA_DIR データディレクトリのパスを上書き
652
+ KAIROS_ALLOW_OPEN_ENDPOINT 1 にすると、ネットワーク到達可能なバインドでもトークン無しで起動(危険)
649
653
  ```
650
654
 
651
- #### 本番環境でのHTTPSデプロイ
655
+ #### HTTPS / TLS
652
656
 
653
- 本番運用では、Pumaの前段にリバースプロキシを配置してTLS/HTTPSを処理します。Puma内部はプレーンHTTPのみを処理し、リバースプロキシがSSLを終端します。
657
+ MCPエンドポイントをTLSで提供する方法は2つあります。暗号化は常に Puma/OpenSSL が行い、KairosChain は通信路(平文HTTP か TLS か)を選ぶだけです。
658
+
659
+ **オプションA: 内蔵TLS(単独運用者 / 遠隔アクセス)**
660
+
661
+ KairosChain は自分でTLSを終端できるので、別途プロキシを立てなくても自ホストのインスタンスに安全に接続できます。
662
+
663
+ ```bash
664
+ # 1. 自己署名の証明書/鍵を一度だけ生成(<data-dir>/storage/tls/ に出力)。
665
+ # 接続に使うホスト名/IPを含めておくと、証明書の検証が通ります:
666
+ kairos-chain --gen-cert --cert-host myhost.example.com
667
+
668
+ # 2. TLSで起動(または skills/config.yml で http.tls.enabled: true を設定):
669
+ kairos-chain --http --tls
670
+ ```
671
+
672
+ 補足:
673
+ - 証明書は自己署名なので、クライアント(Cursor / Claude Code)側で信頼する(または検証を無効化する)必要があります。認証局発行の証明書が必要な場合はオプションBを使ってください。
674
+ - 証明書には有効期限(既定で約2年)があります。サーバは起動時に期限を記録し、期限が近いと警告します。更新は、古い証明書を削除して `--gen-cert` を再実行します。
675
+ - 管理者トークンが未設定の場合、ネットワーク到達可能なバインドでは起動を拒否します(無認証のowner露出になるため)。先に `--init-admin` を実行するか、意図的に無認証で起動する場合は `KAIROS_ALLOW_OPEN_ENDPOINT=1` を設定します。
676
+
677
+ **オプションB: リバースプロキシ(公開 / マルチユーザー)**
678
+
679
+ 公開・マルチユーザー運用では、Pumaの前段にリバースプロキシを配置します。プロキシが認証局発行の証明書でTLSを終端します。
654
680
 
655
681
  ```
656
682
  クライアント (Cursor) ──HTTPS──▶ リバースプロキシ ──HTTP──▶ Puma (:8080)
@@ -658,7 +684,7 @@ Usage: kairos-chain [command] [options]
658
684
  TLS終端
659
685
  ```
660
686
 
661
- **オプションA: Caddy(推奨 — 最もシンプル)**
687
+ **Caddy(推奨 — 最もシンプル)**
662
688
 
663
689
  CaddyはLet's Encrypt証明書による自動HTTPS(TLSの設定不要)を提供します。
664
690
 
@@ -190,6 +190,21 @@ http:
190
190
  token_store: "storage/tokens.json" # Token storage path (uses SQLite if backend is sqlite)
191
191
  default_token_expiry_days: 90 # Default token expiry (days)
192
192
 
193
+ # -----------------------------------------------------------------------
194
+ # TLS (HTTPS) — optional transport encryption for remote access
195
+ # -----------------------------------------------------------------------
196
+ # Encryption is delegated to Puma/OpenSSL; KairosChain only selects the
197
+ # bind scheme (Prop 2: transport is execution substrate, not core logic).
198
+ # For a single operator, generate a self-signed cert once, then enable:
199
+ # kairos-chain --gen-cert
200
+ # kairos-chain --http --tls # or set enabled: true below
201
+ # For a public service, prefer a reverse proxy (e.g. Caddy) with a
202
+ # CA-issued certificate instead of a self-signed cert.
203
+ tls:
204
+ enabled: false # opt-in; false = plain HTTP (default)
205
+ cert: "storage/tls/cert.pem" # certificate path (relative to data dir)
206
+ key: "storage/tls/key.pem" # private key path (chmod 0600)
207
+
193
208
  # -----------------------------------------------------------------------
194
209
  # Phase 2 (Future): Role-Based Authorization
195
210
  # -----------------------------------------------------------------------
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.39.0
4
+ version: 3.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-05 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -172,6 +172,8 @@ files:
172
172
  - lib/kairos_mcp/storage/file_backend.rb
173
173
  - lib/kairos_mcp/storage/importer.rb
174
174
  - lib/kairos_mcp/storage/sqlite_backend.rb
175
+ - lib/kairos_mcp/tls_cert_generator.rb
176
+ - lib/kairos_mcp/tls_config.rb
175
177
  - lib/kairos_mcp/tool_registry.rb
176
178
  - lib/kairos_mcp/tools/base_tool.rb
177
179
  - lib/kairos_mcp/tools/capability_status.rb