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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +27 -2
  5. data/bin/kairos-plugin-project +0 -0
  6. data/bin/kairos-readable-gate +60 -0
  7. data/lib/kairos_mcp/admin/helpers.rb +7 -1
  8. data/lib/kairos_mcp/anthropic_skill_parser.rb +19 -5
  9. data/lib/kairos_mcp/context_manager.rb +50 -8
  10. data/lib/kairos_mcp/knowledge_provider.rb +167 -17
  11. data/lib/kairos_mcp/path_containment.rb +120 -0
  12. data/lib/kairos_mcp/plugin_projector.rb +18 -11
  13. data/lib/kairos_mcp/resource_registry.rb +80 -12
  14. data/lib/kairos_mcp/tools/skills_promote.rb +11 -2
  15. data/lib/kairos_mcp/version.rb +1 -1
  16. data/templates/skillsets/agent/test/test_agent_halt_semantics.rb +362 -0
  17. data/templates/skillsets/agent/test/test_decide_prompt_contract.rb +46 -0
  18. data/templates/skillsets/agent/tools/agent_step.rb +65 -4
  19. data/templates/skillsets/external_tools/tools/safe_file_edit.rb +14 -2
  20. data/templates/skillsets/kairos_hook_projector/hooks/readable_gate.rb +692 -0
  21. data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_compiler.rb +359 -0
  22. data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_locator.rb +55 -0
  23. data/templates/skillsets/kairos_hook_projector/lib/mode_hooks_schema.rb +199 -0
  24. data/templates/skillsets/kairos_hook_projector/mode_hooks/_EXAMPLE.json +102 -0
  25. data/templates/skillsets/kairos_hook_projector/mode_hooks/_record_schema.json +87 -0
  26. data/templates/skillsets/kairos_hook_projector/mode_hooks/_schema.json +69 -5
  27. data/templates/skillsets/kairos_hook_projector/plugin/SKILL.md +53 -22
  28. data/templates/skillsets/kairos_hook_projector/skillset.json +9 -4
  29. data/templates/skillsets/kairos_hook_projector/test/test_boot_time_assertion.rb +4 -4
  30. data/templates/skillsets/kairos_hook_projector/test/test_hooks_status.rb +165 -16
  31. data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_compiler.rb +431 -0
  32. data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_locator.rb +98 -0
  33. data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_project.rb +845 -0
  34. data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_schema.rb +38 -6
  35. data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_validate.rb +1026 -0
  36. data/templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb +1174 -0
  37. data/templates/skillsets/kairos_hook_projector/test/test_skillset_json.rb +10 -7
  38. data/templates/skillsets/kairos_hook_projector/tools/hooks_status.rb +86 -33
  39. data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_project.rb +585 -0
  40. data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_validate.rb +643 -0
  41. data/templates/skillsets/project_manager/knowledge/project_manager_guide/project_manager_guide.md +4 -3
  42. data/templates/skillsets/project_manager/lib/project_manager/store.rb +105 -2
  43. data/templates/skillsets/project_manager/plugin/agents/secretary.md +36 -5
  44. data/templates/skillsets/project_manager/skillset.json +2 -2
  45. data/templates/skillsets/project_manager/test/test_project_manager.rb +129 -0
  46. data/templates/skillsets/project_manager/tools/pm_item.rb +30 -3
  47. metadata +37 -4
  48. 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: 4ba66e678675e9b4e7776676832765d509f0ac7bb49feed159ee1513ba4b7318
4
- data.tar.gz: 28af1484af3a0c98529603bfa30fd9ff188e5b18895c06c11a313bcf3a95d8b2
3
+ metadata.gz: 0b5f8504e1a59fe2e58603f654ad32a747381897a62dba70ba436388c5be3f55
4
+ data.tar.gz: c4c86ca90fb56858a45c126aca497277846225c1b127549700be1e80484c0755
5
5
  SHA512:
6
- metadata.gz: 67e98820708350d674970e3db49f3583efc01491ba8d58f5ecb702b988f64a5ad1efeb083637f45e77897c44dbb30a84569400431e8f669020fb13a2dae7b4aa
7
- data.tar.gz: 27fef53ab90cdb4a7745955b49d41e3834d8313f0a0417605a96e21e84b0ab44fcb5c0e3ad27d9ff2cf698bda2a75c0dc7712869ab5e686daeb8cc468da77cdf
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
  [![Gem Version](https://img.shields.io/gem/v/kairos-chain)](https://rubygems.org/gems/kairos-chain)
4
- [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-red)](https://www.ruby-lang.org/)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red)](https://www.ruby-lang.org/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- See [CLAUDE.md](../CLAUDE.md) for the full philosophical framework including the Nine Propositions.
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
 
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
- return [404, {}, ['Not found']] unless File.exist?(filepath)
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')].first
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
- unless File.directory?(context_dir)
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