kairos-chain 3.35.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/bin/kairos-chain +170 -4
  4. data/lib/kairos_mcp/http_server.rb +106 -5
  5. data/lib/kairos_mcp/tls_cert_generator.rb +113 -0
  6. data/lib/kairos_mcp/tls_config.rb +149 -0
  7. data/lib/kairos_mcp/version.rb +1 -1
  8. data/templates/knowledge/design_to_implementation_workflow/design_to_implementation_workflow.md +5 -0
  9. data/templates/knowledge/kairoschain_setup/kairoschain_setup.md +37 -4
  10. data/templates/knowledge/kairoschain_setup_jp/kairoschain_setup_jp.md +30 -4
  11. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +181 -2
  12. data/templates/skills/config.yml +15 -0
  13. data/templates/skillsets/synoptis/config/synoptis.yml +10 -0
  14. data/templates/skillsets/synoptis/lib/synoptis/constitutive/attestation_chain.rb +53 -3
  15. data/templates/skillsets/synoptis/lib/synoptis/constitutive/content_attestation_entry.rb +17 -4
  16. data/templates/skillsets/synoptis/lib/synoptis/constitutive/proposal_criterion.rb +11 -3
  17. data/templates/skillsets/synoptis/lib/synoptis/constitutive/revocation_withdrawal_entry.rb +60 -0
  18. data/templates/skillsets/synoptis/lib/synoptis/constitutive/subject_ref.rb +43 -6
  19. data/templates/skillsets/synoptis/lib/synoptis/tool_helpers.rb +14 -1
  20. data/templates/skillsets/synoptis/lib/synoptis.rb +1 -0
  21. data/templates/skillsets/synoptis/skillset.json +3 -1
  22. data/templates/skillsets/synoptis/test/test_l2_constitutive_slice1.rb +4 -1
  23. data/templates/skillsets/synoptis/test/test_l2_constitutive_slice2.rb +121 -0
  24. data/templates/skillsets/synoptis/test/test_l2_constitutive_slice3.rb +117 -0
  25. data/templates/skillsets/synoptis/tools/l2_attestation_commit.rb +68 -20
  26. data/templates/skillsets/synoptis/tools/l2_attestation_revoke.rb +104 -0
  27. data/templates/skillsets/synoptis/tools/l2_attestation_scan.rb +8 -4
  28. data/templates/skillsets/synoptis/tools/l2_attestation_view.rb +64 -0
  29. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 790cf38c2263e065c856211e0dce54a56a4e9599b2b028dbd3335405f78d069f
4
- data.tar.gz: 3a568dc9d900c4aefc24572657043c82d94ea62027a349f6c3e20094d778cbb7
3
+ metadata.gz: 2285b28e2f2954ca10f6da869b7446b4d18fdc0f9174451f20ec8f523142b322
4
+ data.tar.gz: 9a14c47fd141de6ff7eaec10d6e56c44f01e96b1cb0b9ab962eedb4baccc8e4b
5
5
  SHA512:
6
- metadata.gz: a3d7d364ecfe3512de1ca75375eefe890002b4c228f7dd3245ea00277abe1552030261a3916ef27ec3c1e968381646ac73a0811672016a8d16d6fab282954c31
7
- data.tar.gz: 1bc3370f34c14841d0b57c471ff9e71185521bfe36b583c788732f6513d453c619a07e53070bb6e4d674b6b3b2ae4ec716663f53247d65e9a380a968e942752c
6
+ metadata.gz: 89a5a8a47b38f8f703283c58d9e402efeab1a2124fa1ad2195c0820edd985301ce68b22dd40012614499d09f9b34410905e473ca33ccad97e8d32d804ba46eb1
7
+ data.tar.gz: df15f0d5f6cdb22039840cf780b235cba8194e77da2738478b37dbeb446c25814609887083566388bae2abeb2f067e0aa2ecb24d16b4ca626b75644818d794eb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,166 @@ 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
+
38
+ ## [3.39.0] - 2026-07-05
39
+
40
+ ### Added — L1 `multi_llm_review_workflow` v3.5 → v3.6: Step 0.25 Unknowns Pass
41
+
42
+ Adds a pre-draft unknown-discovery discipline to the multi-LLM review workflow,
43
+ adapted from Thariq Shihipar's "A Field Guide to Fable: Finding Your Unknowns"
44
+ (2026-07-03), techniques ① blindspot pass and ③ one-question-at-a-time interview.
45
+ Bundled L1 knowledge only — no code paths changed.
46
+
47
+ - **Step 0.25 (pre-draft, qualifying reviews only)**: before drafting a design or
48
+ knowledge/documentation artifact routed to full multi-LLM review, the orchestrator
49
+ enumerates its blind-spot questions (without self-answering) and interviews the human
50
+ one question at a time, then triages every surfaced unknown to Resolved / Declared /
51
+ Draft-deferred. Moves unknown discovery from the expensive channel (review rounds) to
52
+ the cheap channel (pre-draft dialogue).
53
+ - **Five invariants INV-U1–U5**: front-load gate (no dispatch before the pass);
54
+ human gate (no orchestrator self-answering); constitutive recording (unconditional L2
55
+ record, incl. zero-result); bounded demotion of declared unknowns (provenance bound +
56
+ inverted default, closing a laundering channel); human-only classification authority.
57
+ - **Unattended (autonomous loop) clause**: fail-closed — every surfaced unknown is
58
+ judgment-shaped by default and stops the run; only a human-authored mandate may
59
+ pre-classify categories as non-judgment-shaped, and unattended declarations gain no
60
+ reviewer-demotion power until human-ratified. Mandate expressiveness, ratification
61
+ protocol, and the pending-question queue are deferred to the Autonomous Growth Loop
62
+ guard track.
63
+ - Three-location L1 sync (instance `.kairos/knowledge/`, gem `knowledge/`, gem
64
+ `templates/knowledge/`); change recorded to blockchain (hash a74a11b7…). One-line
65
+ cross-reference added to `design_to_implementation_workflow`.
66
+ - Designed via its own 3-round self-referential multi-LLM review (R1 REVISE 2/6 →
67
+ R2 REVISE 2/6 → R3 4/6 APPROVE; design record
68
+ `docs/drafts/multi_llm_review_unknowns_pass_v0.3.1_FROZEN.md`).
69
+
70
+ ## [3.38.0] - 2026-07-05
71
+
72
+ ### Added — Synoptis L2 attestation: ACT-5 trigger sources + scan CLI (Slice 3b/3c)
73
+
74
+ Makes the ACT-5 trigger point distinguishable and invocable from a session-end
75
+ Stop hook, so firing + trigger-record logging can be guaranteed rather than relying
76
+ on the orchestrator remembering to run the tool.
77
+
78
+ - **Trigger source (3b)**: `l2_attestation_scan` gains a `trigger_source` argument
79
+ (`manual` / `orchestrator_session_end` / `session_end_hook`), recorded in the trigger
80
+ record (`AttestationChain#append_trigger(source:)`). ACT-5's "at least one defined
81
+ trigger point" is now distinguishable in the operational log, and the tool documents
82
+ its two auto-firing paths (orchestrator-at-session-end for delivery+approval; Stop hook
83
+ for a mechanical liveness guarantee).
84
+ - **Scan CLI (3c)**: new `kairos-chain attestation scan [--session SID] [--source SRC]
85
+ [--data-dir DIR]`. It loads the constitutive criterion, proposes a session's
86
+ judgment-bearing contexts, and appends a trigger record — propose-only, never attests
87
+ (ACT-1 keeps attestation human-approved through the MCP tool). This is the shell
88
+ entry point a session-end Stop hook calls. Verified end-to-end.
89
+
90
+ The two firing mechanisms are complementary: the orchestrator convention delivers
91
+ proposals to a present human for approval; the Stop hook guarantees the trigger fires
92
+ and is logged even when the orchestrator does not. The settings.json Stop-hook wiring is
93
+ a per-project harness step (not shipped in the gem). 17 Slice-3 unit tests green (incl.
94
+ trigger-source); Slices 1 (35) and 2 (25) unchanged.
95
+
96
+ ## [3.37.0] - 2026-07-05
97
+
98
+ ### Added — Synoptis L2 attestation: semantic criterion + snapshot (Slice 3a)
99
+
100
+ Matures the ACT-2 selection criterion and adds the optional content snapshot
101
+ (§Kinds), without embedding an LLM call inside the tool (selective-survival: the
102
+ in-Ruby LLM scoring is deferred until the frontmatter criterion is observed to be
103
+ insufficient).
104
+
105
+ - **Semantic layer (ACT-2)**: `l2_attestation_scan` now attaches a bounded content
106
+ `preview` to each proposal and surfaces a `rubric` (config `semantic_rubric`). The
107
+ orchestrator applies the rubric to the previews to decide which frontmatter-filtered
108
+ candidates to propose to the human — the LLM-semantic judgment lives in the
109
+ orchestrator, not in the tool. The criterion stays inspectable and revisable by
110
+ editing config (`judgment_types`, `semantic_rubric`, `preview_chars`).
111
+ - **Snapshot (§Kinds optional)**: `l2_attestation_commit(embed_snapshot: true)` embeds
112
+ the subject's current content in the entry, so audits needing the original wording
113
+ have it (LED-2a preserves it). The snapshot's SHA256 equals the entry digest, so it is
114
+ self-verifying. Bounded by `max_snapshot_bytes` (default 256KB); oversize content is
115
+ refused (`snapshot_too_large`) rather than truncated.
116
+ - `SubjectRef` gains `content_text` / `content_preview`.
117
+
118
+ Deferred (until observed need): in-Ruby `llm_client` scoring of candidates; session-end
119
+ auto-firing of the trigger (ACT-5 liveness is currently satisfied by explicit/orchestrator
120
+ scan). 12 Slice-3 unit tests + 4 tool integration tests green; Slices 1 (35) and 2 (25)
121
+ unchanged.
122
+
123
+ ## [3.36.0] - 2026-07-05
124
+
125
+ ### Added — Synoptis L2 attestation: supersession + revocation (Slice 2)
126
+
127
+ Records the rewrite/withdrawal lifecycle of a constitutive attestation by appending,
128
+ never editing (the bank-ledger correction model). Completes §Kinds' two attestation-
129
+ ledger entry kinds and satisfies LED-2b (lineage) and ACT-3 (approval binding).
130
+
131
+ - **Supersession**: a re-attestation of an already-attested subject is now a
132
+ supersession — `l2_attestation_commit` auto-derives the current head and commits its
133
+ entry_id as `target_ref` (ContentAttestationEntry gains `target_ref`). The first
134
+ attestation about a subject carries none.
135
+ - **Revocation-withdrawal**: new `RevocationWithdrawalEntry` kind (commits subject +
136
+ target_ref + moment, no digest/content) and a new `l2_attestation_revoke` tool. It
137
+ appends a withdrawal of the subject's current head; like any ledger entry it requires
138
+ human approval (ACT-1) and is itself append-only, so "claimed then withdrawn" survives.
139
+ - **ACT-3 binding**: the commit/revoke proposal returns the digest and the target it
140
+ would act on; the approving call echoes them as `expected_digest` /
141
+ `expected_target_ref`. If the live content changed (digest_mismatch) or the head moved
142
+ (target_moved) between proposal and approval, the append is refused, not silent.
143
+ - **Fold**: `AttestationChain#current_head` / `current_state` fold a subject's entries
144
+ (first → supersessions → withdrawals) into a status (none/attested/withdrawn); a new
145
+ read-only `l2_attestation_view` tool surfaces it. Fold ordering rule fixed per §11: a
146
+ revocation clears the head only when it targets the current head.
147
+
148
+ Synoptis's trust engine (duplicate hard-reject, TTL, revocation store) is untouched —
149
+ the constitutive ledger is a separate chain, so re-attest-as-supersession is native and
150
+ no reconciliation was needed. 25 Slice-2 unit tests + 14 tool integration tests green;
151
+ Slice 1 (35) unchanged.
152
+
153
+ ## [3.35.1] - 2026-07-05
154
+
155
+ ### Fixed — Synoptis L2 attestation: frontmatter parsing dropped all proposals
156
+
157
+ `l2_attestation_scan` surfaced zero proposals against real L2 contexts because
158
+ `SubjectRef.extract_frontmatter` used `YAML.safe_load` without permitting `Date`:
159
+ a `date:` field (present in every real context's frontmatter) raised
160
+ `Psych::DisallowedClass`, which was rescued to `nil`, so the `type` was never read
161
+ and no context matched the criterion. Caught by live end-to-end verification, not
162
+ by unit tests (whose fixtures lacked a `date:` field). Fix permits `[Date, Time]`
163
+ and adds a regex fallback for the single `type` scalar so the criterion is robust
164
+ against any frontmatter YAML that `safe_load` rejects. Unit fixtures now carry a
165
+ `date:` field to guard the regression.
166
+
7
167
  ## [3.35.0] - 2026-07-05
8
168
 
9
169
  ### Added — Synoptis: L2 constitutive attestation (Slice 1)
data/bin/kairos-chain CHANGED
@@ -343,6 +343,86 @@ when 'upgrade'
343
343
  end
344
344
  exit
345
345
 
346
+ when 'attestation'
347
+ # Constitutive L2 attestation CLI (Slice 3c). Currently one action: `scan`, the
348
+ # ACT-5 trigger point invokable from a session-end Stop hook so that firing +
349
+ # trigger-record logging is guaranteed even when the orchestrator does not run the
350
+ # MCP tool. It proposes only and logs a trigger record; it never attests (ACT-1
351
+ # keeps attestation human-approved through the MCP tool).
352
+ ARGV.shift # Remove 'attestation'
353
+
354
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
355
+ require 'kairos_mcp'
356
+
357
+ if (idx = ARGV.index('--data-dir'))
358
+ KairosMcp.data_dir = File.expand_path(ARGV[idx + 1])
359
+ ARGV.delete_at(idx + 1)
360
+ ARGV.delete_at(idx)
361
+ end
362
+
363
+ action = ARGV.shift
364
+ case action
365
+ when 'scan'
366
+ require 'json'
367
+ require 'yaml'
368
+ require 'fileutils'
369
+
370
+ session = (i = ARGV.index('--session')) ? ARGV[i + 1] : nil
371
+ source = (i = ARGV.index('--source')) ? ARGV[i + 1] : 'session_end_hook'
372
+
373
+ syn_lib = File.join(KairosMcp.skillsets_dir, 'synoptis', 'lib')
374
+ unless File.directory?(syn_lib)
375
+ puts JSON.generate(status: 'error', message: 'synoptis skillset not installed')
376
+ exit 0
377
+ end
378
+ $LOAD_PATH.unshift(syn_lib)
379
+ require 'synoptis/registry/file_registry'
380
+ require 'synoptis/constitutive/subject_ref'
381
+ require 'synoptis/constitutive/proposal_criterion'
382
+ require 'synoptis/constitutive/attestation_chain'
383
+
384
+ context_dir = KairosMcp.context_dir
385
+ session ||= if Dir.exist?(context_dir)
386
+ Dir.children(context_dir)
387
+ .select { |c| File.directory?(File.join(context_dir, c)) }
388
+ .max_by { |c| File.mtime(File.join(context_dir, c)) }
389
+ end
390
+ unless session
391
+ puts JSON.generate(status: 'error', message: 'no session found to scan')
392
+ exit 0
393
+ end
394
+
395
+ cfg_path = File.join(KairosMcp.skillsets_dir, 'synoptis', 'config', 'synoptis.yml')
396
+ const_cfg = (File.exist?(cfg_path) ? (YAML.safe_load(File.read(cfg_path)) || {}) : {})['constitutive'] || {}
397
+
398
+ data_dir = File.join(KairosMcp.data_dir, 'synoptis_data')
399
+ FileUtils.mkdir_p(data_dir)
400
+ registry = Synoptis::Registry::FileRegistry.new(data_dir: data_dir)
401
+ chain = Synoptis::Constitutive::AttestationChain.new(registry: registry)
402
+ criterion = Synoptis::Constitutive::ProposalCriterion.new(
403
+ context_dir: context_dir,
404
+ judgment_types: const_cfg['judgment_types'] ||
405
+ Synoptis::Constitutive::ProposalCriterion::DEFAULT_JUDGMENT_TYPES,
406
+ preview_chars: const_cfg['preview_chars'] ||
407
+ Synoptis::Constitutive::ProposalCriterion::DEFAULT_PREVIEW_CHARS
408
+ )
409
+
410
+ proposals = criterion.propose(session_id: session)
411
+ chain.append_trigger(surfaced_count: proposals.length, source: source)
412
+ puts JSON.generate(
413
+ status: 'proposed',
414
+ session_id: session,
415
+ trigger_source: source,
416
+ surfaced_count: proposals.length,
417
+ proposals: proposals
418
+ )
419
+ exit 0
420
+ else
421
+ warn "Unknown attestation action: #{action.inspect}"
422
+ warn 'Usage: kairos-chain attestation scan [--session SID] [--source SRC] [--data-dir DIR]'
423
+ exit 1
424
+ end
425
+
346
426
  when 'mode'
347
427
  ARGV.shift # Remove 'mode' from ARGV
348
428
 
@@ -556,10 +636,22 @@ OptionParser.new do |opts|
556
636
  options[:port] = port
557
637
  end
558
638
 
559
- 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|
560
640
  options[:host] = host
561
641
  end
562
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
+
563
655
  opts.on('--init-admin', 'Generate initial admin token and exit') do
564
656
  options[:init_admin] = true
565
657
  end
@@ -635,6 +727,72 @@ if options[:version]
635
727
  exit
636
728
  end
637
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
+
638
796
  # Handle --init-admin
639
797
  if options[:init_admin]
640
798
  require 'kairos_mcp/auth/token_store'
@@ -654,6 +812,13 @@ if options[:init_admin]
654
812
  store_path = File.join(KairosMcp.data_dir, store_path)
655
813
  end
656
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
+
657
822
  store = KairosMcp::Auth::TokenStore.create(
658
823
  backend: http_config['token_backend'],
659
824
  store_path: store_path
@@ -710,9 +875,9 @@ if options[:init_admin]
710
875
  $stderr.puts " {"
711
876
  $stderr.puts " \"mcpServers\": {"
712
877
  $stderr.puts " \"kairos\": {"
713
- $stderr.puts " \"url\": \"http://localhost:#{options[:port] || 8080}/mcp\","
878
+ $stderr.puts " \"url\": \"#{admin_scheme}://localhost:#{options[:port] || 8080}/mcp\","
714
879
  $stderr.puts " \"headers\": {"
715
- $stderr.puts " \"Authorization\": \"Bearer #{result['raw_token']}\""
880
+ $stderr.puts " \"Authorization\": \"Bearer #{result[:raw_token] || result['raw_token']}\""
716
881
  $stderr.puts " }"
717
882
  $stderr.puts " }"
718
883
  $stderr.puts " }"
@@ -737,7 +902,8 @@ if options[:http]
737
902
  server = KairosMcp::HttpServer.new(
738
903
  port: options[:port],
739
904
  host: options[:host],
740
- token_store_path: options[:token_store]
905
+ token_store_path: options[:token_store],
906
+ tls: options[:tls]
741
907
  )
742
908
  server.run
743
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