kairos-chain 3.64.0 → 3.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/LICENSE +21 -0
- data/README.md +27 -2
- data/bin/kairos-plugin-project +0 -0
- data/bin/kairos-readable-gate +60 -0
- data/lib/kairos_mcp/admin/helpers.rb +7 -1
- data/lib/kairos_mcp/anthropic_skill_parser.rb +19 -5
- data/lib/kairos_mcp/context_manager.rb +50 -8
- data/lib/kairos_mcp/knowledge_provider.rb +167 -17
- data/lib/kairos_mcp/path_containment.rb +120 -0
- data/lib/kairos_mcp/plugin_projector.rb +18 -11
- data/lib/kairos_mcp/resource_registry.rb +80 -12
- data/lib/kairos_mcp/tools/skills_promote.rb +11 -2
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/test/test_agent_halt_semantics.rb +362 -0
- data/templates/skillsets/agent/test/test_decide_prompt_contract.rb +46 -0
- data/templates/skillsets/agent/tools/agent_step.rb +65 -4
- data/templates/skillsets/external_tools/tools/safe_file_edit.rb +14 -2
- data/templates/skillsets/kairos_hook_projector/hooks/readable_gate.rb +692 -0
- data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_compiler.rb +359 -0
- data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_locator.rb +55 -0
- data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_schema.rb +199 -0
- data/templates/skillsets/kairos_hook_projector/mode_hooks/_EXAMPLE.json +102 -0
- data/templates/skillsets/kairos_hook_projector/mode_hooks/_record_schema.json +87 -0
- data/templates/skillsets/kairos_hook_projector/mode_hooks/_schema.json +69 -5
- data/templates/skillsets/kairos_hook_projector/plugin/SKILL.md +53 -22
- data/templates/skillsets/kairos_hook_projector/skillset.json +9 -4
- data/templates/skillsets/kairos_hook_projector/test/test_boot_time_assertion.rb +4 -4
- data/templates/skillsets/kairos_hook_projector/test/test_hooks_status.rb +165 -16
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_compiler.rb +431 -0
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_locator.rb +98 -0
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_project.rb +845 -0
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_schema.rb +38 -6
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_validate.rb +1026 -0
- data/templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb +1174 -0
- data/templates/skillsets/kairos_hook_projector/test/test_skillset_json.rb +10 -7
- data/templates/skillsets/kairos_hook_projector/tools/hooks_status.rb +86 -33
- data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_project.rb +585 -0
- data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_validate.rb +643 -0
- data/templates/skillsets/project_manager/knowledge/project_manager_guide/project_manager_guide.md +4 -3
- data/templates/skillsets/project_manager/lib/project_manager/store.rb +105 -2
- data/templates/skillsets/project_manager/plugin/agents/secretary.md +36 -5
- data/templates/skillsets/project_manager/skillset.json +2 -2
- data/templates/skillsets/project_manager/test/test_project_manager.rb +129 -0
- data/templates/skillsets/project_manager/tools/pm_item.rb +30 -3
- metadata +37 -4
- data/templates/skillsets/kairos_hook_projector/plugin/hooks.json +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b5f8504e1a59fe2e58603f654ad32a747381897a62dba70ba436388c5be3f55
|
|
4
|
+
data.tar.gz: c4c86ca90fb56858a45c126aca497277846225c1b127549700be1e80484c0755
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3535630f908f30fc87319e960207ea5552de1f29284211cf24e4802171a7699b3ebcde4ebce74875341d368004ea04ccf36211f4b68037755907a1ef77550ae6
|
|
7
|
+
data.tar.gz: a01fbf7861eac890a2d020ce8a0606970d41a7a9f3d0f1840f1db7a19580c87f77b77af1ffebd4028c538f4535b562db78adff1c847389c2fd6a162dda5fa3f4
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ 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.65.0] - 2026-08-13
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Ruby 3.2 is now the minimum.** The `readable_gate` Stop hook bounds its
|
|
12
|
+
scan with `Regexp.timeout`, which arrived in Ruby 3.2. Under the previous
|
|
13
|
+
floor of 3.0 that call raised, the gate's outermost rescue converted it to
|
|
14
|
+
exit 0, and the hook enforced nothing while reporting nothing. Guarding the
|
|
15
|
+
call was the alternative and is worse: without `Regexp.timeout` there is no
|
|
16
|
+
per-match bound either, so a declaration carrying many patterns could
|
|
17
|
+
consume the hook's whole budget and stall a turn. An install that fails is
|
|
18
|
+
louder than a gate that does not measure. Ruby 3.0 left security support in
|
|
19
|
+
April 2024 and 3.1 in March 2025.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`kairos_hook_projector` reaches stage 2.** The SkillSet gains two tools
|
|
24
|
+
that write the harness configuration — `mode_hooks_project`, which installs
|
|
25
|
+
a mode's declared hooks into `.claude/settings.json` behind a proposal and
|
|
26
|
+
a confirmation hash, and `mode_hooks_validate` — and two executables land on
|
|
27
|
+
PATH, `kairos-readable-gate` and `kairos-plugin-project`. Before this
|
|
28
|
+
release the SkillSet was read-only. `kairos-chain-daemon` also joins the
|
|
29
|
+
executable list; it shipped in the package but was never put on PATH.
|
|
30
|
+
- **`kairos_hook_projector` gains a diagram floor.** A mode may declare
|
|
31
|
+
`diagram_required_over_lines`, and a message whose prose exceeds it while
|
|
32
|
+
carrying no fenced block is reported. It is the first floor among the
|
|
33
|
+
gate's thresholds, which are otherwise all caps, and the length
|
|
34
|
+
announcement deliberately does not clear it. Off unless a mode names a
|
|
35
|
+
number; the core supplies no threshold of its own.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- The gate no longer supplies a rewrite instruction of its own. The block
|
|
40
|
+
reason is addressed under the mode's name, so core-authored prose arrived
|
|
41
|
+
as something the mode had said. A mode that declares none now gets none.
|
|
42
|
+
- A `.claude/settings.json` shape the projector cannot merge is refused rather
|
|
43
|
+
than silently discarded.
|
|
44
|
+
- The measurement deadline reaches inside a single pattern's matches and into
|
|
45
|
+
the specimen scan, not only the loop between patterns.
|
|
46
|
+
- A log rotation that cannot be performed no longer costs the record it was
|
|
47
|
+
making room for.
|
|
48
|
+
- The gate exits 0 when `--config` is absent, as its documented invariant
|
|
49
|
+
always claimed; it exited 2, which a Stop hook reads as blocking.
|
|
50
|
+
- `LICENSE` now exists under the gem build root. It was listed in
|
|
51
|
+
`spec.files` and absent from the published package.
|
|
52
|
+
|
|
7
53
|
## [3.64.0] - 2026-08-06
|
|
8
54
|
|
|
9
55
|
### Fixed
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 masaomi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# KairosChain MCP Server
|
|
2
2
|
|
|
3
3
|
[](https://rubygems.org/gems/kairos-chain)
|
|
4
|
-
[](https://www.ruby-lang.org/)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
7
|
A self-referential [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for auditable skill self-management. KairosChain enables AI agents to define, evolve, and audit their own capabilities through a three-layer knowledge system backed by a private blockchain.
|
|
@@ -108,7 +108,32 @@ kairos-chain -v # Show version
|
|
|
108
108
|
|
|
109
109
|
KairosChain's architecture flows from one principle: **meta-level operations are expressed in the same structure as base-level operations.** This structural self-referentiality enables agents to reason about, modify, and evolve their own capabilities using the same tools they use for base-level tasks.
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
### Why "Pure" Skills
|
|
112
|
+
|
|
113
|
+
The name is borrowed from *pure functions* — functions whose output depends only on their declared
|
|
114
|
+
arguments, and which change nothing outside their return value. A capability written in prose is
|
|
115
|
+
impure in two ways. Its behaviour depends on things the definition does not declare: the rest of the
|
|
116
|
+
context window, the model version, whatever else happens to be loaded. And what it actually did
|
|
117
|
+
cannot be checked from the definition, either before the run or after it.
|
|
118
|
+
|
|
119
|
+
Purity here does not mean the absence of effects. Agents do write files and reach out to the world.
|
|
120
|
+
It means that **where the impurity lives is declared in the definition itself.** Inside a
|
|
121
|
+
`definition` block, `constraint` and `check` carry the machine-decidable part, while the fifth
|
|
122
|
+
constructor, `node`, marks the part a machine cannot decide. The evaluation engine returns those
|
|
123
|
+
nodes as non-evaluable and leaves satisfaction as `:unknown` rather than true or false:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# SemanticReasoning — explicitly non-evaluable (requires human judgment)
|
|
127
|
+
satisfied: :unknown,
|
|
128
|
+
evaluable: false
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Like a type system that surfaces effects instead of removing them, the undecidable part is recorded
|
|
132
|
+
as undecidable rather than hidden. How much of a capability has been formalised therefore becomes
|
|
133
|
+
machine-readable — which is what `dsl_ast/drift_detector.rb` measures.
|
|
134
|
+
|
|
135
|
+
See [CLAUDE.md](../CLAUDE.md) for the full philosophical framework including the Nine Propositions,
|
|
136
|
+
and the `kairoschain_design` L1 knowledge entry for the Pure Skills directory structure.
|
|
112
137
|
|
|
113
138
|
## Author
|
|
114
139
|
|
data/bin/kairos-plugin-project
CHANGED
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Harness-side entry point for the readable-output gate.
|
|
5
|
+
#
|
|
6
|
+
# Usage (as a Claude Code Stop hook, produced by mode_hooks_project):
|
|
7
|
+
# kairos-readable-gate --config <path/to/gate_config.json>
|
|
8
|
+
#
|
|
9
|
+
# Why this entry point exists rather than the hook naming the gate directly:
|
|
10
|
+
#
|
|
11
|
+
# 1. PluginProjector only projects hook commands matching /\Akairos-/ without
|
|
12
|
+
# warning. A raw path command warns on every projection, and a warning
|
|
13
|
+
# nobody can act on trains operators to ignore warnings.
|
|
14
|
+
# 2. The gate's location differs between an installed SkillSet and the gem's
|
|
15
|
+
# own templates. Resolving it here keeps that off the compiled artifact,
|
|
16
|
+
# which must stay a pure function of (mode identity, declaration).
|
|
17
|
+
#
|
|
18
|
+
# It used to carry a third job — finding a python3 — which went away with the
|
|
19
|
+
# port to Ruby on 2026-08-12. The gate now runs in this process rather than a
|
|
20
|
+
# spawned one, so a turn pays one interpreter start instead of two.
|
|
21
|
+
#
|
|
22
|
+
# stdin, stdout and the exit status are passed through untouched: this entry
|
|
23
|
+
# point must never turn a gate verdict into something else. If the gate cannot
|
|
24
|
+
# be found or cannot be run, it exits 0 and emits nothing — a broken gate must
|
|
25
|
+
# not wedge a session.
|
|
26
|
+
|
|
27
|
+
SCRIPT_REL = File.join('skillsets', 'kairos_hook_projector', 'hooks', 'readable_gate.rb')
|
|
28
|
+
|
|
29
|
+
def candidate_scripts
|
|
30
|
+
paths = []
|
|
31
|
+
paths << ENV['KAIROS_READABLE_GATE_SCRIPT'] if ENV['KAIROS_READABLE_GATE_SCRIPT']
|
|
32
|
+
|
|
33
|
+
# Installed SkillSet, under the instance data dir.
|
|
34
|
+
data_dir = ENV['KAIROS_DATA_DIR']
|
|
35
|
+
data_dir ||= File.join(Dir.pwd, '.kairos')
|
|
36
|
+
paths << File.join(data_dir, SCRIPT_REL)
|
|
37
|
+
|
|
38
|
+
# The gem's own templates, for an instance that has not acquired the SkillSet.
|
|
39
|
+
gem_root = File.expand_path('..', __dir__)
|
|
40
|
+
paths << File.join(gem_root, 'templates', SCRIPT_REL)
|
|
41
|
+
|
|
42
|
+
paths
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
script = candidate_scripts.find { |p| p && File.file?(p) }
|
|
46
|
+
|
|
47
|
+
if script.nil?
|
|
48
|
+
warn '[kairos-readable-gate] not run: gate script not found'
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
code = begin
|
|
53
|
+
require script
|
|
54
|
+
KairosHookProjector::ReadableGate.main
|
|
55
|
+
rescue StandardError, SystemStackError, LoadError => e
|
|
56
|
+
warn "[kairos-readable-gate] not run: #{e.class}: #{e.message}"
|
|
57
|
+
0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
exit code
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'erb'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require 'digest'
|
|
6
|
+
require_relative '../path_containment'
|
|
6
7
|
|
|
7
8
|
module KairosMcp
|
|
8
9
|
module Admin
|
|
@@ -88,8 +89,13 @@ module KairosMcp
|
|
|
88
89
|
# @param filename [String] File name in static/ directory
|
|
89
90
|
# @return [Array] Rack response triple
|
|
90
91
|
def serve_static(filename)
|
|
92
|
+
# filename comes straight from PATH_INFO on the one admin route that
|
|
93
|
+
# requires no authentication, so containment is what stands between an
|
|
94
|
+
# anonymous request and any file this process can read.
|
|
91
95
|
filepath = File.join(STATIC_DIR, filename)
|
|
92
|
-
|
|
96
|
+
unless PathContainment.contained?(STATIC_DIR, filepath) && File.file?(filepath)
|
|
97
|
+
return [404, {}, ['Not found']]
|
|
98
|
+
end
|
|
93
99
|
|
|
94
100
|
content_type = case File.extname(filename)
|
|
95
101
|
when '.css' then 'text/css'
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'securerandom'
|
|
6
|
+
require_relative 'path_containment'
|
|
6
7
|
|
|
7
8
|
module KairosMcp
|
|
8
9
|
# AnthropicSkillParser: Parses Anthropic skills format (YAML frontmatter + Markdown)
|
|
@@ -150,7 +151,7 @@ module KairosMcp
|
|
|
150
151
|
def list_scripts(skill)
|
|
151
152
|
return [] unless skill.has_scripts?
|
|
152
153
|
|
|
153
|
-
Dir[File.join(skill.scripts_path, '*')].map do |f|
|
|
154
|
+
Dir[File.join(skill.scripts_path, '*')].select { |f| inside_entry?(skill.base_path, f) }.map do |f|
|
|
154
155
|
{
|
|
155
156
|
name: File.basename(f),
|
|
156
157
|
path: f,
|
|
@@ -167,7 +168,7 @@ module KairosMcp
|
|
|
167
168
|
def list_assets(skill)
|
|
168
169
|
return [] unless skill.has_assets?
|
|
169
170
|
|
|
170
|
-
Dir[File.join(skill.assets_path, '**/*')].select { |f| File.file?(f) }.map do |f|
|
|
171
|
+
Dir[File.join(skill.assets_path, '**/*')].select { |f| File.file?(f) && inside_entry?(skill.base_path, f) }.map do |f|
|
|
171
172
|
{
|
|
172
173
|
name: File.basename(f),
|
|
173
174
|
path: f,
|
|
@@ -185,7 +186,7 @@ module KairosMcp
|
|
|
185
186
|
def list_references(skill)
|
|
186
187
|
return [] unless skill.has_references?
|
|
187
188
|
|
|
188
|
-
Dir[File.join(skill.references_path, '**/*')].select { |f| File.file?(f) }.map do |f|
|
|
189
|
+
Dir[File.join(skill.references_path, '**/*')].select { |f| File.file?(f) && inside_entry?(skill.base_path, f) }.map do |f|
|
|
189
190
|
{
|
|
190
191
|
name: File.basename(f),
|
|
191
192
|
path: f,
|
|
@@ -230,14 +231,27 @@ module KairosMcp
|
|
|
230
231
|
end
|
|
231
232
|
end
|
|
232
233
|
|
|
234
|
+
# An entry's markdown file lives inside that entry's directory. That is
|
|
235
|
+
# true by the definition of the format, so it is enforced here rather
|
|
236
|
+
# than left to each of the callers to remember.
|
|
237
|
+
#
|
|
238
|
+
# Both branches need it. The named branch can be a symlink pointing out
|
|
239
|
+
# of the entry, and the glob branch returns whatever the glob happened to
|
|
240
|
+
# find, which can be the same. Without this, reading an entry returned
|
|
241
|
+
# content from wherever the link led — ResourceRegistry defends its own
|
|
242
|
+
# read against exactly this, and the parser did not.
|
|
233
243
|
def find_md_file(skill_dir)
|
|
234
244
|
# First try to find a file with the same name as the directory
|
|
235
245
|
skill_name = File.basename(skill_dir)
|
|
236
246
|
expected_file = File.join(skill_dir, "#{skill_name}.md")
|
|
237
|
-
return expected_file if File.exist?(expected_file)
|
|
247
|
+
return expected_file if File.exist?(expected_file) && inside_entry?(skill_dir, expected_file)
|
|
238
248
|
|
|
239
249
|
# Fall back to any .md file in the directory
|
|
240
|
-
Dir[File.join(skill_dir, '*.md')].
|
|
250
|
+
Dir[File.join(skill_dir, '*.md')].find { |f| inside_entry?(skill_dir, f) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def inside_entry?(skill_dir, path)
|
|
254
|
+
PathContainment.contained?(skill_dir, path)
|
|
241
255
|
end
|
|
242
256
|
end
|
|
243
257
|
end
|
|
@@ -4,6 +4,7 @@ require 'fileutils'
|
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require_relative 'anthropic_skill_parser'
|
|
6
6
|
require_relative 'context_graph'
|
|
7
|
+
require_relative 'path_containment'
|
|
7
8
|
require_relative '../kairos_mcp'
|
|
8
9
|
|
|
9
10
|
module KairosMcp
|
|
@@ -43,7 +44,10 @@ module KairosMcp
|
|
|
43
44
|
# @param session_id [String] Session ID
|
|
44
45
|
# @return [Array<Hash>] List of context summaries
|
|
45
46
|
def list_contexts_in_session(session_id)
|
|
47
|
+
return [] unless segments?(session_id)
|
|
48
|
+
|
|
46
49
|
session_dir = File.join(@context_dir, session_id)
|
|
50
|
+
return [] unless contained?(session_dir)
|
|
47
51
|
return [] unless File.directory?(session_dir)
|
|
48
52
|
|
|
49
53
|
context_dirs(session_dir).map do |dir|
|
|
@@ -66,7 +70,10 @@ module KairosMcp
|
|
|
66
70
|
# @param name [String] Context name
|
|
67
71
|
# @return [AnthropicSkillParser::SkillEntry, nil] The context entry or nil
|
|
68
72
|
def get_context(session_id, name)
|
|
73
|
+
return nil unless segments?(session_id, name)
|
|
74
|
+
|
|
69
75
|
context_dir = File.join(@context_dir, session_id, name)
|
|
76
|
+
return nil unless contained?(context_dir)
|
|
70
77
|
return nil unless File.directory?(context_dir)
|
|
71
78
|
|
|
72
79
|
AnthropicSkillParser.parse(context_dir)
|
|
@@ -83,10 +90,18 @@ module KairosMcp
|
|
|
83
90
|
validate_relations_in_content!(content)
|
|
84
91
|
|
|
85
92
|
session_dir = File.join(@context_dir, session_id)
|
|
86
|
-
FileUtils.mkdir_p(session_dir)
|
|
87
|
-
|
|
88
93
|
context_dir = File.join(session_dir, name)
|
|
89
94
|
|
|
95
|
+
# Checked before mkdir_p, so neither value can create a directory outside
|
|
96
|
+
# the store. Both guards are needed: containment bounds the composed leaf,
|
|
97
|
+
# and the segment check bounds session_dir and the second join the parser
|
|
98
|
+
# performs on `name` — paths this method never composes itself.
|
|
99
|
+
unless segments?(session_id, name) && contained?(context_dir)
|
|
100
|
+
return { success: false, error: "Invalid session_id/name: '#{session_id}/#{name}' resolves outside the context directory" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
FileUtils.mkdir_p(session_dir)
|
|
104
|
+
|
|
90
105
|
if File.directory?(context_dir)
|
|
91
106
|
# Update existing
|
|
92
107
|
skill = AnthropicSkillParser.update(context_dir, content)
|
|
@@ -140,8 +155,10 @@ module KairosMcp
|
|
|
140
155
|
# @return [Hash] Result with success status
|
|
141
156
|
def delete_context(session_id, name)
|
|
142
157
|
context_dir = File.join(@context_dir, session_id, name)
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
|
|
159
|
+
# rm_rf below: containment is checked first so a traversing name cannot
|
|
160
|
+
# delete a tree outside the context store.
|
|
161
|
+
unless segments?(session_id, name) && contained?(context_dir) && File.directory?(context_dir)
|
|
145
162
|
return { success: false, error: "Context '#{name}' not found in session '#{session_id}'" }
|
|
146
163
|
end
|
|
147
164
|
|
|
@@ -155,8 +172,8 @@ module KairosMcp
|
|
|
155
172
|
# @return [Hash] Result with success status
|
|
156
173
|
def delete_session(session_id)
|
|
157
174
|
session_dir = File.join(@context_dir, session_id)
|
|
158
|
-
|
|
159
|
-
unless File.directory?(session_dir)
|
|
175
|
+
|
|
176
|
+
unless segments?(session_id) && contained?(session_dir) && File.directory?(session_dir)
|
|
160
177
|
return { success: false, error: "Session '#{session_id}' not found" }
|
|
161
178
|
end
|
|
162
179
|
|
|
@@ -178,13 +195,21 @@ module KairosMcp
|
|
|
178
195
|
end
|
|
179
196
|
|
|
180
197
|
context_dir = File.join(@context_dir, session_id, name)
|
|
181
|
-
unless File.directory?(context_dir)
|
|
198
|
+
unless segments?(session_id, name) && contained?(context_dir) && File.directory?(context_dir)
|
|
182
199
|
return { success: false, error: "Context '#{name}' not found in session '#{session_id}'" }
|
|
183
200
|
end
|
|
184
201
|
|
|
185
202
|
subdir_path = File.join(context_dir, subdir)
|
|
203
|
+
unless contained?(subdir_path)
|
|
204
|
+
return { success: false, error: "Invalid subdir '#{subdir}': resolves outside the context directory" }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# mkdir_p raises Errno::EEXIST when the name is taken by a dangling
|
|
208
|
+
# symlink, and this method's contract is a result hash, not an exception.
|
|
186
209
|
FileUtils.mkdir_p(subdir_path)
|
|
187
210
|
{ success: true, path: subdir_path }
|
|
211
|
+
rescue SystemCallError => e
|
|
212
|
+
{ success: false, error: "Could not create subdir '#{subdir}': #{e.message}" }
|
|
188
213
|
end
|
|
189
214
|
|
|
190
215
|
# Generate a unique session ID
|
|
@@ -223,12 +248,29 @@ module KairosMcp
|
|
|
223
248
|
|
|
224
249
|
private
|
|
225
250
|
|
|
251
|
+
# True if a path built from a caller-supplied session_id / name still
|
|
252
|
+
# resolves inside the context store.
|
|
253
|
+
def contained?(path)
|
|
254
|
+
PathContainment.contained?(@context_dir, path)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# True if every caller-supplied value names one directory.
|
|
258
|
+
#
|
|
259
|
+
# Containment alone is not enough here: "." and ".." resolve back onto the
|
|
260
|
+
# store root, which is inside it, so a delete addressed that way would take
|
|
261
|
+
# the whole store. A session id and a context name are always one level.
|
|
262
|
+
def segments?(*values)
|
|
263
|
+
values.all? { |v| PathContainment.safe_segment?(v) }
|
|
264
|
+
end
|
|
265
|
+
|
|
226
266
|
def session_dirs
|
|
227
267
|
Dir[File.join(@context_dir, '*')].select { |f| File.directory?(f) }
|
|
228
268
|
end
|
|
229
269
|
|
|
270
|
+
# Listing follows Dir entries, so a child that is a symlink out of the store
|
|
271
|
+
# would otherwise be enumerated and parsed as if it were a context.
|
|
230
272
|
def context_dirs(session_dir)
|
|
231
|
-
Dir[File.join(session_dir, '*')].select { |f| File.directory?(f) }
|
|
273
|
+
Dir[File.join(session_dir, '*')].select { |f| File.directory?(f) && contained?(f) }
|
|
232
274
|
end
|
|
233
275
|
end
|
|
234
276
|
end
|