kairos-chain 3.88.1 → 3.88.2

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: c37ee945277e2917d116b02aeb8b47957f97363d8428aae7b54e17a732ca0d94
4
- data.tar.gz: 62df9a6d5f05afd01c3aae950959d6398a22e979270183a154e4fd211d2b78c8
3
+ metadata.gz: 8a785289fdb24dc921cad6c727b74168557a7dcf90593cb7ae267d2cb6b3501f
4
+ data.tar.gz: 422bb014ce8b479f765972b4f05fe57f86c71c09bae7c48277e8625b798767af
5
5
  SHA512:
6
- metadata.gz: 74101139a4c243c77119530a5a723c0e1de9fdb6224c1b5f776f78b250040c1e4aade60b93cb75d5cbef3afac2609e2832763a079fc38bddd6ecac84e73f2c32
7
- data.tar.gz: 0bf5337f8e6b5985967711300dfe64554e7ff55bb67f7f1dff1d3fca1aefa28fa74677477d5ed3c32cc9b843556434ca692a7254d26a74d68a32807408091621
6
+ metadata.gz: b399c8a6e440d323ce30c73fb1c62acbb6fcefe7c67e76b89bed4171a3a9bc57d3af48de2ffb4cfc9ac4a802498dde65a868e0435564824d1f29a638748f3e73
7
+ data.tar.gz: 6b2fc4894c4d52489bb86580386a50121c64ebcdc7f1e363e18813f0a9a548d2a9f974104206fc66d4255b00119cb6f9d4c4b3b81523776a365c4b37be58779b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,69 @@ 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.88.2] - 2026-10-06
8
+
9
+ ### Security — agent: the act and its LLM bodies stay inside their gates
10
+
11
+ A design review of approval delegation found several ways for the agent to get
12
+ around the gates it runs inside: the tool blacklist, the risk budget and the
13
+ guard. Each was confirmed in code; none was observed in use. Four review rounds
14
+ followed. **The act route is still deny-based. Do not run the agent unattended
15
+ until an act-route allow-list lands** (next version, designed with approval
16
+ delegation).
17
+
18
+ **No LLM body launched for the agent carries tools in the project root.**
19
+ - **Agent phases.** ORIENT, DECIDE, REFLECT and the persona reviews launched
20
+ `claude -p` in the project root. It read the project's instruction files and
21
+ inherited its permission rules (on one instance, `Bash(*)`, `Write` and
22
+ `Edit`). Every agent LLM call is now sandboxed.
23
+ - **llm_call steps in the act.** The driver forces `sandbox_mode` on them. A
24
+ step whose provider carries tools (codex, cursor, codex_mcp), or any provider
25
+ outside claude_code / anthropic / openai / bedrock, is refused before the
26
+ act. This includes the instance's default provider when the step names
27
+ none, and the default provider used by `write_section`.
28
+ - **`write_section`.** Its section writer now always calls the LLM sandboxed.
29
+ - **multi_llm_review cursor seat.** It ran `agent -p` (every tool, write and
30
+ shell included) in the server's working directory. It now runs
31
+ `agent -p --trust --mode plan` (read-only) in an empty directory made for
32
+ each call. Cursor reviews therefore see only the artifact.
33
+
34
+ **An act does not touch what constrains the agent or its operator.**
35
+ - **Protected locations.** A step whose path arguments, at any depth and
36
+ whatever the tool, reach a protected location is refused before ACT. The
37
+ protected locations are:
38
+ - the KairosChain stores (`.kairos`: config, sessions, L1, the instruction
39
+ mode, the chain, the keys);
40
+ - `.claude` and `.codex`;
41
+ - `.mcp.json`, `CLAUDE.md` and `AGENTS.md`;
42
+ - anything outside the project (`$HOME` and `/` never count as the
43
+ project).
44
+ - **How paths are judged.** Paths are resolved against the step's own root
45
+ arguments and against the tools' default roots, so a decoy root cannot move
46
+ the check. `..` segments, unresolvable paths and dangling symlinks are
47
+ refused. A symlink is judged by its target.
48
+ - **Recording.** The refusal is recorded on the chain (`agent_act_refused`,
49
+ with the rule that fired) whether or not the guard is on.
50
+ - **Record-store writers** (`chain_record`, attestation and state commits) are
51
+ now refused on the act route with the guard off as well. The record judges
52
+ the act, so the act may not write it.
53
+ - **Configuration writers** (`llm_configure`, `mode_hooks_add` /
54
+ `mode_hooks_project`, `plugin_project`), `hermes_*` and `multi_llm_review*`
55
+ are refused on the act route. All of these are also hidden from DECIDE's
56
+ tool catalog, and DECIDE's norms name the rule.
57
+ - **Instances whose `safety.yml` sets no `safe_root`.** The file tools resolve
58
+ relative paths inside the stores there, so the act refuses them. The refusal
59
+ says to set `safe_root`.
60
+
61
+ **Not covered yet.**
62
+ - `.git/hooks` and `.git/config`, `.cursor*`, `.gemini`,
63
+ `.github/copilot-instructions.md`, `.envrc` and `.vscode/tasks.json` are not
64
+ in the protected set.
65
+ - A path-less tool not named above is not stopped (the allow-list will).
66
+ - `agent_execute` remains unregistered.
67
+ - In an operator's own multi_llm_review runs, the codex seat can still read
68
+ the repository.
69
+
7
70
  ## [3.88.1] - 2026-10-06
8
71
 
9
72
  ### Fixed — agent guard: the verdict reaches the record
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.88.1"
2
+ VERSION = "3.88.2"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -54,8 +54,208 @@ module KairosMcp
54
54
  # route, where the scratch area quarantines it until the verdict.
55
55
  LIVE_TREE_WRITE_TOOLS = %w[write_section sc_scaffold plugin_project].freeze
56
56
 
57
+ # What constrains the agent or its operator, and so may not be touched
58
+ # by an act (read or written) through any path argument of any tool:
59
+ # the KairosChain stores (the agent's own config and session files,
60
+ # L1, the instruction mode, the chain), the harness configuration
61
+ # (.claude: permission rules and hooks that run as the operator;
62
+ # .codex; .mcp.json) and the instruction files every agent body reads
63
+ # (CLAUDE.md, AGENTS.md). Checked by destination, not by tool name, so
64
+ # a tool that was not anticipated is covered by its arguments.
65
+ PROTECTED_SEGMENTS = %w[.kairos .claude .codex].freeze
66
+ PROTECTED_BASENAMES = %w[claude.md agents.md .mcp.json].freeze
67
+
68
+ # Argument names that carry a filesystem location, matched by word
69
+ # (split on '_' / '-') so 'profile' or 'output_format' is not a path.
70
+ # Tokens come from splitting on '_', '-' and camelCase. A token also
71
+ # matches when it ends in 'dir' or 'path' (workdir, outdir, filepath)
72
+ # — not 'file', or 'profile' would match.
73
+ PATH_TOKENS = %w[path paths file files filename filenames dir dirs dirname directory dest
74
+ destination source target root workspace src dst cwd folder location].freeze
75
+ PATH_SUFFIXES = %w[dir path].freeze
76
+ ROOT_TOKENS = %w[root workspace cwd].freeze
77
+
78
+ # Tools an act may not call, with or without the guard: they rewrite
79
+ # the configuration that governs every later call (llm_configure:
80
+ # provider, endpoint and which key is sent; the mode-hook tools and
81
+ # plugin_project: .claude hooks and permission surfaces) without
82
+ # naming a path, or launch an external agent body (hermes_*).
83
+ # multi_llm_review is here too: its codex seat runs `codex exec` (read-
84
+ # only, but able to read files) in the project root, and its cursor
85
+ # seat may read absolute paths, so a plan-authored artifact could have
86
+ # a seat read the instance's keys. The driver's own review gate is not
87
+ # the act route and is unaffected.
88
+ ACT_CONFIG_WRITERS = %w[llm_configure mode_hooks_add mode_hooks_project plugin_project hermes_*
89
+ multi_llm_review*].freeze
90
+
91
+ # llm_call providers an act may use: claude_code runs sandboxed (the
92
+ # driver forces sandbox_mode); the API providers carry no tools. CLI
93
+ # bodies that carry tools (codex, codex_mcp, cursor) and any provider
94
+ # not named here are refused.
95
+ # (openrouter is not listed: without a configured base_url it falls
96
+ # back to the OpenAI endpoint and would send the OpenRouter key there.)
97
+ ACT_SAFE_LLM_PROVIDERS = %w[claude_code anthropic openai bedrock].freeze
98
+
99
+ # Tools that call llm_call internally with the instance's default
100
+ # provider (no override possible from the plan).
101
+ DEFAULT_PROVIDER_LLM_TOOLS = %w[write_section].freeze
102
+
57
103
  module_function
58
104
 
105
+ # Steps of a plan that would touch a protected location or leave the
106
+ # project, as [{ 'step_id', 'tool_name', 'path' }].
107
+ # protected_dirs absolute protected directories (the stores,
108
+ # <project>/.claude, <project>/.codex)
109
+ # roots every directory a tool may resolve a relative path
110
+ # against
111
+ # allowed_roots where an act may touch anything at all (the
112
+ # project); a path or a root argument resolving
113
+ # outside them is refused, so an explicit
114
+ # workspace_root cannot turn the check into a
115
+ # deny-list over the whole disk
116
+ # Every argument at any depth whose name is path-like is checked.
117
+ # Fail-closed throughout: arguments that are not an object, a '..'
118
+ # segment, an unresolvable path and a dangling symlink are hits.
119
+ # Comparison ignores case (APFS is case-insensitive by default).
120
+ def protected_path_violations(task_json, protected_dirs, roots, allowed_roots = nil)
121
+ protected = Array(protected_dirs).map { |d| safe_canonical(d).downcase }
122
+ allowed = Array(allowed_roots).compact.map { |d| safe_canonical(d) }
123
+ steps = task_json.is_a?(Hash) ? task_json['steps'] : nil
124
+ return [] unless steps.is_a?(Array)
125
+
126
+ steps.flat_map do |step|
127
+ next [{ 'step_id' => nil, 'tool_name' => nil, 'path' => '(malformed step)' }] unless step.is_a?(Hash)
128
+
129
+ args = step['tool_arguments']
130
+ next [] if args.nil?
131
+ unless args.is_a?(Hash)
132
+ next [{ 'step_id' => step['step_id'], 'tool_name' => step['tool_name'], 'path' => '(arguments not an object)' }]
133
+ end
134
+
135
+ pairs = path_pairs(args)
136
+ explicit_roots = pairs.select { |k, _| (key_tokens(k) & ROOT_TOKENS).any? }.map(&:last)
137
+ # Resolve against the explicit roots AND the tools' default roots: a
138
+ # root argument in the plan is text the planner writes, while the
139
+ # tool decides where it actually resolves (some tools ignore the
140
+ # argument). Judging only at the plan's root would let a decoy
141
+ # 'workspace_root' move the check away from the real write. On an
142
+ # instance whose default root is the stores this over-refuses; the
143
+ # refusal tells the operator to set safe_root.
144
+ candidate_roots = (explicit_roots + Array(roots)).compact.map(&:to_s).reject(&:empty?)
145
+ # No root to resolve against means the check cannot be made.
146
+ next [{ 'step_id' => step['step_id'], 'tool_name' => step['tool_name'], 'path' => '(no root)',
147
+ 'rule' => 'unresolvable' }] if candidate_roots.empty? && !pairs.empty?
148
+ pairs.filter_map do |_key, raw|
149
+ rule = if protected_name?(raw) then 'protected'
150
+ elsif raw.split(%r{[/\\]}).include?('..') then 'dotdot'
151
+ else candidate_roots.lazy.map { |root| touch_rule(raw, root, protected, allowed) }.find(&:itself)
152
+ end
153
+ { 'step_id' => step['step_id'], 'tool_name' => step['tool_name'], 'path' => raw, 'rule' => rule } if rule
154
+ end
155
+ end
156
+ end
157
+
158
+ def key_tokens(key)
159
+ key.to_s.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase.split(/[_\-]/)
160
+ end
161
+
162
+ def path_key?(key)
163
+ key_tokens(key).any? do |t|
164
+ PATH_TOKENS.include?(t) || PATH_SUFFIXES.any? { |suf| t.end_with?(suf) }
165
+ end
166
+ end
167
+
168
+ # [key, string] for every path-like key at any depth; an array takes
169
+ # its key from the parent.
170
+ def path_pairs(node, key = nil)
171
+ case node
172
+ when Hash then node.flat_map { |k, v| path_pairs(v, k) }
173
+ when Array then node.flat_map { |v| path_pairs(v, key) }
174
+ when String
175
+ key && path_key?(key) && !node.strip.empty? ? [[key.to_s, node]] : []
176
+ else []
177
+ end
178
+ end
179
+
180
+ # llm_call steps whose provider would launch a tool-carrying body:
181
+ # [{ 'step_id', 'tool_name', 'path' => '(provider X)', 'rule' }].
182
+ # default_provider is the instance's configured provider (nil when it
183
+ # cannot be read, which refuses steps that do not name a safe one).
184
+ def unsafe_llm_steps(task_json, default_provider)
185
+ steps = task_json.is_a?(Hash) && task_json['steps'].is_a?(Array) ? task_json['steps'] : []
186
+ steps.filter_map do |step|
187
+ next unless step.is_a?(Hash)
188
+
189
+ tool = step['tool_name'].to_s
190
+ next unless tool == 'llm_call' || DEFAULT_PROVIDER_LLM_TOOLS.include?(tool)
191
+
192
+ args = step['tool_arguments'].is_a?(Hash) ? step['tool_arguments'] : {}
193
+ provider = tool == 'llm_call' ? args['provider_override'].to_s : ''
194
+ provider = default_provider.to_s if provider.empty?
195
+ next if ACT_SAFE_LLM_PROVIDERS.include?(provider)
196
+
197
+ { 'step_id' => step['step_id'], 'tool_name' => tool,
198
+ 'path' => "(provider #{provider.empty? ? 'unknown' : provider})", 'rule' => 'unsafe_provider' }
199
+ end
200
+ end
201
+
202
+ # A protected segment or file name anywhere in the path refuses it,
203
+ # whatever root the tool resolves against.
204
+ def protected_name?(raw)
205
+ parts = raw.split(%r{[/\\]}).map(&:downcase)
206
+ parts.any? { |p| PROTECTED_SEGMENTS.include?(p) } || PROTECTED_BASENAMES.include?(parts.last)
207
+ end
208
+
209
+ # True when the path, resolved against root, lands in a protected
210
+ # directory, lies outside every allowed root, or has a protected name
211
+ # once symlinks are resolved. Names are checked only below the allowed
212
+ # root, so a project that itself lives under a protected segment (a
213
+ # Claude Code worktree under .claude/worktrees) is not refused wholesale.
214
+ # The rule a path breaks when resolved against root, or nil.
215
+ # Protected directories compare case-insensitively (fail-closed on
216
+ # case-insensitive filesystems); containment compares with case kept,
217
+ # so on a case-sensitive filesystem /srv/project is not inside
218
+ # /srv/Project. No allowed root at all means nothing is allowed.
219
+ def touch_rule(path, root, protected, allowed)
220
+ full = canonical(File.expand_path(path, root))
221
+ low = full.downcase
222
+ return 'protected' if protected.any? { |dir| low == dir || low.start_with?("#{dir}/") }
223
+ return 'outside' if allowed.empty?
224
+
225
+ base = allowed.select { |dir| full == dir || full.start_with?("#{dir}/") }.max_by(&:length)
226
+ return 'outside' unless base
227
+
228
+ below = full.delete_prefix(base).delete_prefix('/')
229
+ !below.empty? && protected_name?(below) ? 'protected' : nil
230
+ rescue StandardError
231
+ 'unresolvable'
232
+ end
233
+
234
+ def touches?(path, root, protected, allowed)
235
+ !touch_rule(path, root, protected, allowed).nil?
236
+ end
237
+
238
+ # realpath of the deepest existing ancestor, with the rest appended,
239
+ # so a symlink inside the workspace that points at a protected
240
+ # location is seen. A symlink counts as existing even when its target
241
+ # does not, and its realpath then raises: a dangling link is a hit.
242
+ def canonical(path)
243
+ existing = path
244
+ rest = []
245
+ until File.exist?(existing) || File.symlink?(existing) || existing == File.dirname(existing)
246
+ rest.unshift(File.basename(existing))
247
+ existing = File.dirname(existing)
248
+ end
249
+ base = File.exist?(existing) || File.symlink?(existing) ? File.realpath(existing) : existing
250
+ rest.empty? ? base : File.join(base, *rest)
251
+ end
252
+
253
+ def safe_canonical(path)
254
+ canonical(File.expand_path(path.to_s))
255
+ rescue StandardError
256
+ File.expand_path(path.to_s)
257
+ end
258
+
59
259
  # Validate a declared layer surface fail-closed: unknown entries are
60
260
  # refused, and any attempt to declare the record store is refused with
61
261
  # its own message (AGT-5: never declarable).
@@ -160,6 +160,15 @@ module KairosMcp
160
160
  # switch provider, compress, disable thinking).
161
161
  # Falls back to legacy auth_error-only handling when disabled.
162
162
  def call_llm_with_fallback(arguments)
163
+ # Every agent phase reasons; none acts. The agent's tools run through
164
+ # this loop's own tool_use handling, under the session's blacklist.
165
+ # Without sandbox_mode a claude_code provider launches `claude -p`
166
+ # in the project root, where it reads the project's instruction
167
+ # files and inherits its permission rules (a project allowing Bash
168
+ # or Write let a planning call run commands outside every gate).
169
+ # The retry and fallback paths reuse these arguments, so they are
170
+ # sandboxed too.
171
+ arguments = arguments.merge('sandbox_mode' => true)
163
172
  llm_result = @caller.invoke_tool('llm_call', arguments,
164
173
  context: @session.invocation_context)
165
174
  parsed = JSON.parse(llm_result.map { |b| b[:text] || b['text'] }.compact.join)
@@ -48,8 +48,15 @@ the agent to write design drafts to `docs/drafts/` or other project paths.
48
48
 
49
49
  ### MCP Tool Access
50
50
 
51
- All KairosChain MCP tools (`context_save`, `multi_llm_review`, `chain_record`,
52
- `knowledge_get`, etc.) are available via `invoke_tool` in the Act phase.
51
+ KairosChain MCP tools (`context_save`, `knowledge_get`, etc.) are available via
52
+ `invoke_tool` in the Act phase, except what the act route always refuses:
53
+ record-store writers such as `chain_record` (the record judges the act, so the
54
+ act may not write it); configuration writers (`llm_configure`, `mode_hooks_*`,
55
+ `plugin_project`), `hermes_*` and `multi_llm_review*`; `llm_call` with a provider
56
+ that carries tools (codex, cursor); and any step whose path arguments reach the
57
+ KairosChain stores, `.claude/`, `.codex/`, `.mcp.json`, `CLAUDE.md`, `AGENTS.md`
58
+ or anywhere outside the project. LLM calls made by the agent or its act run
59
+ sandboxed. The act route is still deny-based: do not run the agent unattended.
53
60
 
54
61
  ## Sub-Agents
55
62
 
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Three side doors around the agent's gates, closed (found 2026-10-06 in the
5
+ # delegation design review):
6
+ # 1. every agent LLM phase runs `claude -p` sandboxed (no project
7
+ # instruction files, no inherited permission rules, no built-in tools);
8
+ # 2. record-store writers (chain_record, ...) are denied on the act route
9
+ # with the guard off, not only with it on;
10
+ # 3. a plan whose path arguments reach what constrains the agent or its
11
+ # operator (KairosChain stores, .claude, .codex, .mcp.json, CLAUDE.md,
12
+ # AGENTS.md) is refused before ACT, whatever the tool, and the refusal
13
+ # is recorded on the chain;
14
+ # 4. LLM bodies the act launches run sandboxed (llm_call steps,
15
+ # write_section's section writer).
16
+ #
17
+ # Drives the REAL agent_start / agent_step through the registry; stubs only
18
+ # llm_call, autoexec_plan/run and knowledge_get.
19
+ # Usage: ruby test_agent_act_hardening.rb
20
+
21
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
22
+ $LOAD_PATH.unshift File.expand_path('../../../../lib', __dir__)
23
+
24
+ require 'json'
25
+ require 'yaml'
26
+ require 'fileutils'
27
+ require 'tmpdir'
28
+ require 'digest'
29
+ require 'time'
30
+ require 'securerandom'
31
+ require 'kairos_mcp/invocation_context'
32
+ require 'kairos_mcp/tools/base_tool'
33
+ require 'kairos_mcp/tool_registry'
34
+ require_relative '../lib/agent'
35
+ require_relative '../tools/agent_start'
36
+ require_relative '../tools/agent_step'
37
+
38
+ $pass = 0
39
+ $fail = 0
40
+
41
+ def assert(description)
42
+ result = yield
43
+ if result
44
+ $pass += 1
45
+ puts " PASS: #{description}"
46
+ else
47
+ $fail += 1
48
+ puts " FAIL: #{description}"
49
+ end
50
+ rescue StandardError => e
51
+ $fail += 1
52
+ puts " FAIL: #{description} (#{e.class}: #{e.message})"
53
+ puts " #{e.backtrace.first(3).join("\n ")}"
54
+ end
55
+
56
+ def section(title)
57
+ puts "\n#{'=' * 60}\nTEST: #{title}\n#{'=' * 60}"
58
+ end
59
+
60
+ TMPDIR = Dir.mktmpdir('agent_act_hardening')
61
+ PROJECT = File.join(TMPDIR, 'proj')
62
+ STORES = File.join(PROJECT, '.kairos')
63
+ FileUtils.mkdir_p(File.join(STORES, 'skillsets', 'agent', 'config'))
64
+ FileUtils.mkdir_p(File.join(PROJECT, 'docs'))
65
+ # Production runs the server with Dir.pwd = the project root.
66
+ ORIG_PWD = Dir.pwd
67
+ Dir.chdir(PROJECT)
68
+
69
+ module Autonomos
70
+ @storage_base = TMPDIR
71
+ def self.storage_path(subpath)
72
+ path = File.join(@storage_base, subpath)
73
+ FileUtils.mkdir_p(path)
74
+ path
75
+ end
76
+
77
+ def self.config
78
+ {}
79
+ end
80
+ end
81
+
82
+ require File.expand_path('../autonomos/lib/autonomos/mandate', File.dirname(__dir__))
83
+ require File.expand_path('../autonomos/lib/autonomos/ooda', File.dirname(__dir__))
84
+
85
+ # The driver locates the stores through KairosMcp.data_dir.
86
+ module KairosMcp
87
+ def self.data_dir = STORES
88
+ end
89
+ # The ordinary configuration: safety.yml points the tools' root at the
90
+ # project. The stores-root configuration is simulated where tested.
91
+ FileUtils.mkdir_p(File.join(STORES, 'config'))
92
+ File.write(File.join(STORES, 'config', 'safety.yml'), "safe_root: #{PROJECT}\n")
93
+ # The instance's llm_client default provider (claude_code, as shipped).
94
+ FileUtils.mkdir_p(File.join(STORES, 'skillsets', 'llm_client', 'config'))
95
+ File.write(File.join(STORES, 'skillsets', 'llm_client', 'config', 'llm_client.yml'), "provider: claude_code\n")
96
+
97
+ Session = KairosMcp::SkillSets::Agent::Session
98
+ Admission = KairosMcp::SkillSets::Agent::Admission
99
+
100
+ module Autoexec
101
+ class TaskDsl
102
+ def self.from_json(json_str)
103
+ parsed = JSON.parse(json_str)
104
+ raise ArgumentError, 'Missing task_id' unless parsed['task_id']
105
+
106
+ parsed
107
+ end
108
+ end
109
+ end
110
+
111
+ class MockLlmCall < KairosMcp::Tools::BaseTool
112
+ @@responses = []
113
+ @@seen = []
114
+ def self.queue(r) = @@responses << r
115
+ def self.seen = @@seen
116
+
117
+ def self.clear!
118
+ @@responses.clear
119
+ @@seen.clear
120
+ end
121
+
122
+ def name = 'llm_call'
123
+ def description = 'mock'
124
+ def input_schema = { type: 'object', properties: {} }
125
+
126
+ def call(arguments)
127
+ @@seen << arguments
128
+ resp = @@responses.shift || { 'content' => 'default', 'tool_use' => nil, 'stop_reason' => 'end_turn' }
129
+ text_content(JSON.generate({ 'status' => 'ok', 'provider' => 'mock', 'model' => 'mock-1',
130
+ 'response' => resp,
131
+ 'usage' => { 'input_tokens' => 1, 'output_tokens' => 1 },
132
+ 'snapshot' => { 'model' => 'mock-1', 'timestamp' => Time.now.iso8601 } }))
133
+ end
134
+ end
135
+
136
+ class MockAutoexecPlan < KairosMcp::Tools::BaseTool
137
+ @@calls = 0
138
+ def self.calls = @@calls
139
+ def name = 'autoexec_plan'
140
+ def description = 'mock'
141
+ def input_schema = { type: 'object', properties: {} }
142
+
143
+ @@last = nil
144
+ def self.last = @@last
145
+
146
+ def call(arguments)
147
+ @@calls += 1
148
+ task_json = JSON.parse(arguments['task_json'])
149
+ @@last = task_json
150
+ text_content(JSON.generate({ 'status' => 'ok', 'task_id' => task_json['task_id'],
151
+ 'plan_hash' => Digest::SHA256.hexdigest(arguments['task_json'])[0..15],
152
+ 'steps' => task_json['steps']&.length || 0 }))
153
+ end
154
+ end
155
+
156
+ # Keeps the act context it was handed, so the test can ask what it allows.
157
+ class MockAutoexecRun < KairosMcp::Tools::BaseTool
158
+ @@ctx_json = nil
159
+ def self.ctx_json = @@ctx_json
160
+ def name = 'autoexec_run'
161
+ def description = 'mock'
162
+ def input_schema = { type: 'object', properties: {} }
163
+
164
+ def call(arguments)
165
+ @@ctx_json = arguments['invocation_context_json']
166
+ text_content(JSON.generate({ 'task_id' => 'mock_task', 'mode' => 'internal_execute',
167
+ 'outcome' => 'internal_execute_complete', 'steps_processed' => 1, 'steps' => [] }))
168
+ end
169
+ end
170
+
171
+ class MockChainRecord < KairosMcp::Tools::BaseTool
172
+ @@logs = []
173
+ def self.logs = @@logs
174
+ def name = 'chain_record'
175
+ def description = 'mock'
176
+ def input_schema = { type: 'object', properties: {} }
177
+
178
+ def call(arguments)
179
+ @@logs.concat(Array(arguments['logs']))
180
+ text_content("Block ##{@@logs.size} recorded successfully.\nHash: #{'ab' * 32}")
181
+ end
182
+ end
183
+
184
+ class MockLlmConfigure < KairosMcp::Tools::BaseTool
185
+ def name = 'llm_configure'
186
+ def description = 'mock'
187
+ def input_schema = { type: 'object', properties: {} }
188
+ def call(_a) = text_content('{}')
189
+ end
190
+
191
+ class MockKnowledgeGet < KairosMcp::Tools::BaseTool
192
+ def name = 'knowledge_get'
193
+ def description = 'mock'
194
+ def input_schema = { type: 'object', properties: {} }
195
+ def call(args) = text_content(JSON.generate({ 'name' => args['name'], 'content' => 'mock' }))
196
+ end
197
+
198
+ def build_registry
199
+ registry = KairosMcp::ToolRegistry.allocate
200
+ registry.instance_variable_set(:@safety, KairosMcp::Safety.new)
201
+ KairosMcp::ToolRegistry.clear_gates!
202
+ registry.instance_variable_set(:@tools, {
203
+ 'llm_call' => MockLlmCall.new(nil, registry: registry),
204
+ 'knowledge_get' => MockKnowledgeGet.new(nil, registry: registry),
205
+ 'autoexec_plan' => MockAutoexecPlan.new(nil, registry: registry),
206
+ 'autoexec_run' => MockAutoexecRun.new(nil, registry: registry),
207
+ 'chain_record' => MockChainRecord.new(nil, registry: registry),
208
+ 'llm_configure' => MockLlmConfigure.new(nil, registry: registry),
209
+ 'agent_start' => KairosMcp::SkillSets::Agent::Tools::AgentStart.new(nil, registry: registry),
210
+ 'agent_step' => KairosMcp::SkillSets::Agent::Tools::AgentStep.new(registry.instance_variable_get(:@safety), registry: registry)
211
+ })
212
+ registry
213
+ end
214
+
215
+ REGISTRY = build_registry
216
+ START_TOOL = REGISTRY.instance_variable_get(:@tools)['agent_start']
217
+ STEP_TOOL = REGISTRY.instance_variable_get(:@tools)['agent_step']
218
+
219
+ def plan(tool_name, args)
220
+ JSON.generate({
221
+ 'summary' => "plan via #{tool_name}",
222
+ 'task_json' => {
223
+ 'task_id' => "t_#{SecureRandom.hex(3)}", 'meta' => { 'description' => 't', 'risk_default' => 'low' },
224
+ 'steps' => [{ 'step_id' => 's1', 'action' => 'do it', 'tool_name' => tool_name,
225
+ 'tool_arguments' => args, 'risk' => 'low', 'depends_on' => [],
226
+ 'requires_human_cognition' => false }]
227
+ },
228
+ 'review_hint' => { 'needed' => false, 'reason' => nil, 'urgency' => nil },
229
+ 'complexity_hint' => { 'level' => 'low', 'signals' => [] }
230
+ })
231
+ end
232
+
233
+ # risk_budget medium: the file tools are medium-risk, and under the default
234
+ # 'low' budget the plan would pause before ever reaching the store check.
235
+ def run_plan(plan_json)
236
+ sid = JSON.parse(START_TOOL.call({ 'goal_name' => "hardening_#{SecureRandom.hex(3)}",
237
+ 'risk_budget' => 'medium' })[0][:text])['session_id']
238
+ MockLlmCall.clear!
239
+ MockLlmCall.queue({ 'content' => 'orient', 'tool_use' => nil, 'stop_reason' => 'end_turn' })
240
+ MockLlmCall.queue({ 'content' => plan_json, 'tool_use' => nil, 'stop_reason' => 'end_turn' })
241
+ STEP_TOOL.call({ 'session_id' => sid, 'action' => 'approve' })
242
+ MockLlmCall.queue({ 'content' => '{"confidence":0.5}', 'tool_use' => nil, 'stop_reason' => 'end_turn' })
243
+ JSON.parse(STEP_TOOL.call({ 'session_id' => sid, 'action' => 'approve' })[0][:text])
244
+ end
245
+
246
+ # ------------------------------------------------------------------
247
+
248
+ section 'Door 1: every agent LLM phase is sandboxed'
249
+
250
+ run_plan(plan('knowledge_get', { 'name' => 'x' }))
251
+ assert('ORIENT, DECIDE and REFLECT all reached llm_call') { MockLlmCall.seen.size >= 3 }
252
+ assert('every agent llm_call carries sandbox_mode: true') do
253
+ MockLlmCall.seen.all? { |a| a['sandbox_mode'] == true }
254
+ end
255
+
256
+ section 'Door 2: record-store writers are denied on the act route with the guard off'
257
+
258
+ r2 = run_plan(plan('knowledge_get', { 'name' => 'x' }))
259
+ ctx = KairosMcp::InvocationContext.from_json(MockAutoexecRun.ctx_json)
260
+ assert('the guard is off in this session (shipped agent.yml)') { r2['status'] != 'guard_halt' }
261
+ assert('chain_record is refused on the act context') { !ctx.allowed?('chain_record') }
262
+ assert('every record-store writer is refused on the act context') do
263
+ Admission::RECORD_STORE_TOOLS.none? { |t| ctx.allowed?(t) }
264
+ end
265
+ assert('an ordinary tool is still allowed') { ctx.allowed?('knowledge_get') }
266
+ assert('configuration writers are refused on the act context') do
267
+ %w[llm_configure mode_hooks_add mode_hooks_project plugin_project hermes_ask].none? { |t| ctx.allowed?(t) }
268
+ end
269
+
270
+ section 'Door 3: a plan naming a protected location is refused before ACT, and recorded'
271
+
272
+ before = MockAutoexecPlan.calls
273
+ logs_before = MockChainRecord.logs.size
274
+ r3 = run_plan(plan('safe_file_edit', { 'path' => '.kairos/skillsets/agent/config/agent.yml',
275
+ 'old_string' => 'a', 'new_string' => 'b' }))
276
+ assert('the act did not reach autoexec, and not because of a risk pause') do
277
+ MockAutoexecPlan.calls == before && r3['state'] != 'paused_risk'
278
+ end
279
+ assert('the response says a protected location was refused') do
280
+ r3['act_summary'] == 'failed' && r3['act_error'].to_s.include?('protected location')
281
+ end
282
+ assert('the refusal is recorded on the chain (guard off), with the rule that fired') do
283
+ rec = MockChainRecord.logs[logs_before..].map { |l| JSON.parse(l) }.find { |r| r['kind'] == 'agent_act_refused' }
284
+ rec && rec['reason'] == ['protected'] && rec['steps'].first['rule'] == 'protected'
285
+ end
286
+
287
+ before_ws = MockAutoexecPlan.calls
288
+ r_ws = run_plan(plan('write_section', { 'section_name' => 'x', 'instructions' => 'y',
289
+ 'output_file' => '.kairos/skills/masa.md' }))
290
+ assert('write_section into the instruction mode is refused (low-risk tool, default budget path)') do
291
+ MockAutoexecPlan.calls == before_ws && r_ws['act_error'].to_s.include?('protected location')
292
+ end
293
+
294
+ before_ok = MockAutoexecPlan.calls
295
+ r_ok = run_plan(plan('safe_file_write', { 'path' => 'docs/note.md', 'content' => 'x', 'workspace_root' => PROJECT }))
296
+ assert('a write outside the protected locations still runs') do
297
+ MockAutoexecPlan.calls == before_ok + 1 && r_ok['act_error'].nil?
298
+ end
299
+
300
+ before_out = MockAutoexecPlan.calls
301
+ r_out = run_plan(plan('safe_file_write', { 'workspace_root' => Dir.home, 'path' => '.zshrc', 'content' => 'x' }))
302
+ assert('end to end: a write outside the project via an explicit workspace_root is refused') do
303
+ MockAutoexecPlan.calls == before_out && r_out['act_error'].to_s.include?('[outside]')
304
+ end
305
+
306
+ PROTECTED = [STORES, File.join(PROJECT, '.claude'), File.join(PROJECT, '.codex')].freeze
307
+ v = lambda do |tool, args, roots: [PROJECT]|
308
+ Admission.protected_path_violations({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => tool, 'tool_arguments' => args }] },
309
+ PROTECTED, roots, [PROJECT])
310
+ end
311
+ refused = {
312
+ 'an absolute path inside the stores' => ['safe_file_write', { 'path' => File.join(STORES, 'skillsets', 'agent', 'config', 'agent.yml') }],
313
+ 'workspace_root pointing into the stores' => ['safe_file_write', { 'path' => 'skillsets/agent/config/agent.yml', 'workspace_root' => STORES }],
314
+ 'harness settings (.claude) where hooks live' => ['safe_file_write', { 'path' => '.claude/settings.local.json' }],
315
+ 'the project CLAUDE.md' => ['write_section', { 'output_file' => 'CLAUDE.md' }],
316
+ 'a CLAUDE.md in a subdirectory' => ['write_section', { 'output_file' => 'docs/CLAUDE.md' }],
317
+ 'AGENTS.md' => ['safe_file_write', { 'path' => 'AGENTS.md' }],
318
+ '.mcp.json' => ['safe_file_write', { 'path' => '.mcp.json' }],
319
+ 'a different case (.KAIROS)' => ['safe_file_write', { 'path' => '.KAIROS/skills/masa.md' }],
320
+ 'backslash separators' => ['safe_file_write', { 'path' => '.kairos\\skills\\masa.md' }],
321
+ 'a git tool whose workspace_root is the stores repo' => ['safe_git_commit', { 'workspace_root' => '.kairos', 'message' => 'x' }],
322
+ 'sc_scaffold output_path into the stores' => ['sc_scaffold', { 'output_path' => '.kairos/skillsets/evil' }],
323
+ 'copy out of the stores (source)' => ['safe_file_copy', { 'source' => '.kairos/storage/key.pem', 'destination' => 'docs/key.pem' }],
324
+ 'a read of the stores' => ['safe_file_read', { 'path' => '.kairos/storage/key.pem' }],
325
+ 'an explicit workspace_root outside the project' => ['safe_file_write', { 'workspace_root' => Dir.home, 'path' => '.zshrc' }],
326
+ 'an absolute path outside the project' => ['safe_file_write', { 'path' => '/tmp/elsewhere.txt' }],
327
+ 'a .. segment' => ['safe_file_write', { 'path' => 'docs/../../outside.txt' }],
328
+ 'a nested path argument' => ['some_tool', { 'options' => { 'path' => '.kairos/x' } }],
329
+ 'a path inside an array of objects' => ['some_tool', { 'files' => [{ 'path' => 'CLAUDE.md' }] }],
330
+ 'a home harness file via src' => ['some_tool', { 'src' => File.join(Dir.home, '.claude.json') }],
331
+ 'a camelCase path key (filePath)' => ['some_tool', { 'filePath' => '/tmp/elsewhere.txt' }],
332
+ 'a joined path key (workdir)' => ['some_tool', { 'workdir' => '/tmp/elsewhere' }],
333
+ 'a joined path key (outdir)' => ['some_tool', { 'outdir' => '/tmp/elsewhere' }]
334
+ }
335
+ refused.each do |label, (tool, args)|
336
+ assert("refused: #{label}") { v.call(tool, args).size >= 1 }
337
+ end
338
+ assert('refused: a relative path when the tool root is the stores (safety.yml without safe_root)') do
339
+ v.call('safe_file_edit', { 'path' => 'skillsets/agent/config/agent.yml' }, roots: [PROJECT, STORES]).size == 1
340
+ end
341
+ File.symlink(STORES, File.join(PROJECT, 'cfg'))
342
+ assert('refused: a symlink in the workspace that points at the stores') do
343
+ v.call('safe_file_edit', { 'path' => 'cfg/skillsets/agent/config/agent.yml' }).size == 1
344
+ end
345
+ File.write(File.join(PROJECT, 'CLAUDE.md'), '# instructions')
346
+ File.symlink(File.join(PROJECT, 'CLAUDE.md'), File.join(PROJECT, 'docs', 'innocent.md'))
347
+ assert('refused: an innocent-looking symlink whose target is CLAUDE.md') do
348
+ v.call('safe_file_write', { 'path' => 'docs/innocent.md' }).size == 1
349
+ end
350
+ File.symlink(File.join(STORES, 'not_yet.json'), File.join(PROJECT, 'dangling'))
351
+ assert('refused: a dangling symlink') do
352
+ v.call('safe_file_write', { 'path' => 'dangling' }).size == 1
353
+ end
354
+ assert('refused: arguments that are not an object') do
355
+ Admission.protected_path_violations({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'safe_file_write', 'tool_arguments' => '{"path":".kairos/x"}' }] }, PROTECTED, [PROJECT]).size == 1
356
+ end
357
+ assert('no allowed root at all refuses even an ordinary path (fail-closed)') do
358
+ Admission.protected_path_violations({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'safe_file_write',
359
+ 'tool_arguments' => { 'path' => 'docs/note.md' } }] },
360
+ PROTECTED, [PROJECT], []).first&.dig('rule') == 'outside'
361
+ end
362
+ home_step = KairosMcp::SkillSets::Agent::Tools::AgentStep.allocate
363
+ home_step.define_singleton_method(:project_root_for_merge) { Dir.home }
364
+ saved_ws = ENV.delete('KAIROS_WORKSPACE')
365
+ assert('the home directory never counts as the project') { home_step.send(:allowed_roots_for_admission).empty? }
366
+ ENV['KAIROS_WORKSPACE'] = saved_ws if saved_ws
367
+ assert('no root to resolve against is refused, not skipped') do
368
+ Admission.protected_path_violations({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'safe_file_write',
369
+ 'tool_arguments' => { 'path' => 'docs/note.md' } }] },
370
+ PROTECTED, [], [PROJECT]).first&.dig('rule') == 'unresolvable'
371
+ end
372
+
373
+ WT = File.join(TMPDIR, 'repo', '.claude', 'worktrees', 'wt')
374
+ FileUtils.mkdir_p(File.join(WT, 'docs'))
375
+ wt_protected = [File.join(WT, '.kairos'), File.join(WT, '.claude'), File.join(WT, '.codex')]
376
+ wt = lambda do |args|
377
+ Admission.protected_path_violations({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'safe_file_write', 'tool_arguments' => args }] },
378
+ wt_protected, [WT], [WT])
379
+ end
380
+ assert('a project inside a Claude Code worktree (.claude/worktrees) is not refused wholesale') do
381
+ wt.call({ 'path' => 'docs/note.md' }).empty?
382
+ end
383
+ assert('inside such a worktree, its own stores are still refused') do
384
+ wt.call({ 'path' => '.kairos/skills/masa.md' }).size == 1
385
+ end
386
+
387
+ allowed = {
388
+ 'docs/kairos_notes.md' => ['safe_file_write', { 'path' => 'docs/kairos_notes.md' }],
389
+ 'a report under docs' => ['write_section', { 'output_file' => 'docs/report.md' }],
390
+ 'a non-path argument' => ['knowledge_get', { 'name' => 'kairoschain_meta_philosophy' }],
391
+ 'a URI-like resource' => ['resource_read', { 'uri' => 'knowledge://x' }],
392
+ "a non-path key that contains 'file' as a substring" => ['some_tool', { 'profile' => 'a/b' }],
393
+ 'an output_format value' => ['some_tool', { 'output_format' => '/tmp/not_a_path_here' }],
394
+ "profile is not a path key even with a path-shaped value" => ['some_tool', { 'profile' => '/tmp/elsewhere' }]
395
+ }
396
+ allowed.each do |label, (tool, args)|
397
+ assert("allowed: #{label}") { v.call(tool, args).empty? }
398
+ end
399
+
400
+ # An instance whose safety.yml sets no safe_root resolves file tools against
401
+ # the data dir itself. Simulate that root and plan a path with no protected
402
+ # name in it: only resolving against the tools' own root can catch it.
403
+ safety = STEP_TOOL.instance_variable_get(:@safety)
404
+ raise 'the step tool must hold a real Safety' unless safety.is_a?(KairosMcp::Safety)
405
+ safety.define_singleton_method(:safe_root) { STORES }
406
+ before_sr = MockAutoexecPlan.calls
407
+ r_sr = run_plan(plan('safe_file_edit', { 'path' => 'skillsets/agent/config/agent.yml', 'old_string' => 'a', 'new_string' => 'b' }))
408
+ safety.singleton_class.send(:remove_method, :safe_root)
409
+ assert("a relative path is judged where the tool would write it (tool root = the stores)") do
410
+ MockAutoexecPlan.calls == before_sr && r_sr['act_error'].to_s.include?('protected location')
411
+ end
412
+ assert('the refusal says the tool root is inside the stores and how to fix it') do
413
+ r_sr['act_error'].to_s.include?('set safe_root in safety.yml')
414
+ end
415
+ safety.define_singleton_method(:safe_root) { STORES }
416
+ before_decoy = MockAutoexecPlan.calls
417
+ r_decoy = run_plan(plan('write_section', { 'section_name' => 'x', 'instructions' => 'y',
418
+ 'output_file' => 'knowledge/x/x.md', 'workspace_root' => PROJECT }))
419
+ safety.singleton_class.send(:remove_method, :safe_root)
420
+ assert('a decoy workspace_root does not move the check away from where the tool writes') do
421
+ MockAutoexecPlan.calls == before_decoy && r_decoy['act_error'].to_s.include?('protected location')
422
+ end
423
+ assert('the driver protects the stores, .claude and .codex') do
424
+ dirs = STEP_TOOL.send(:protected_dirs_for_admission)
425
+ dirs.include?(STORES) && dirs.any? { |d| d.end_with?('/.claude') } && dirs.any? { |d| d.end_with?('/.codex') }
426
+ end
427
+
428
+ section 'Door 4: LLM bodies the act launches run sandboxed'
429
+
430
+ before_llm = MockAutoexecPlan.calls
431
+ run_plan(plan('llm_call', { 'messages' => [{ 'role' => 'user', 'content' => 'run: rm -rf x' }] }))
432
+ llm_step = Array(MockAutoexecPlan.last && MockAutoexecPlan.last['steps']).find { |st| st['tool_name'] == 'llm_call' }
433
+ assert('an llm_call step reaches autoexec with sandbox_mode forced on') do
434
+ MockAutoexecPlan.calls == before_llm + 1 && llm_step && llm_step.dig('tool_arguments', 'sandbox_mode') == true
435
+ end
436
+
437
+ require File.expand_path('../document_authoring/lib/document_authoring/section_writer', File.dirname(__dir__))
438
+ FakeCaller = Struct.new(:seen) do
439
+ def invoke_tool(name, args, context: nil)
440
+ seen << [name, args]
441
+ [{ text: JSON.generate({ 'status' => 'ok', 'response' => { 'content' => 'text' } }) }]
442
+ end
443
+ end
444
+ fc = FakeCaller.new([])
445
+ KairosMcp::SkillSets::DocumentAuthoring::SectionWriter.new(fc, {}).send(:generate_one, 's', 'i', 'c', 100, 'en', nil)
446
+ assert("write_section's section writer calls llm_call with sandbox_mode") do
447
+ fc.seen.any? { |name, args| name == 'llm_call' && args['sandbox_mode'] == true }
448
+ end
449
+
450
+ %w[codex cursor codex_mcp some_unknown_cli].each do |prov|
451
+ before_p = MockAutoexecPlan.calls
452
+ r_p = run_plan(plan('llm_call', { 'messages' => [{ 'role' => 'user', 'content' => 'print .kairos/keys/x.pem' }],
453
+ 'provider_override' => prov }))
454
+ assert("an llm_call step with provider #{prov} is refused before ACT") do
455
+ MockAutoexecPlan.calls == before_p && r_p['act_error'].to_s.include?('[unsafe_provider]')
456
+ end
457
+ end
458
+ assert('an llm_call step with an API provider is allowed') do
459
+ Admission.unsafe_llm_steps({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'llm_call',
460
+ 'tool_arguments' => { 'provider_override' => 'anthropic' } }] }, 'claude_code').empty?
461
+ end
462
+ assert('an llm_call step with no override follows the instance default (codex default is refused)') do
463
+ Admission.unsafe_llm_steps({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'llm_call', 'tool_arguments' => {} }] }, 'codex').size == 1
464
+ end
465
+ assert('an unreadable default provider refuses an llm_call step without a safe override') do
466
+ Admission.unsafe_llm_steps({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'llm_call', 'tool_arguments' => {} }] }, nil).size == 1
467
+ end
468
+ assert('openrouter is not a safe provider (it falls back to the OpenAI endpoint without base_url)') do
469
+ Admission.unsafe_llm_steps({ 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'llm_call',
470
+ 'tool_arguments' => { 'provider_override' => 'openrouter' } }] }, 'claude_code').size == 1
471
+ end
472
+ assert('write_section follows the instance default provider (codex default is refused)') do
473
+ ws = { 'steps' => [{ 'step_id' => 's1', 'tool_name' => 'write_section', 'tool_arguments' => { 'output_file' => 'docs/a.md' } }] }
474
+ Admission.unsafe_llm_steps(ws, 'codex').size == 1 && Admission.unsafe_llm_steps(ws, 'claude_code').empty?
475
+ end
476
+ assert('multi_llm_review is refused on the act context') { !ctx.allowed?('multi_llm_review') && !ctx.allowed?('multi_llm_review_collect') }
477
+
478
+ section 'The planner is not offered tools the act route always refuses'
479
+
480
+ catalog = STEP_TOOL.send(:build_tool_catalog, Session.load(JSON.parse(START_TOOL.call({ 'goal_name' => 'catalog_check' })[0][:text])['session_id']))
481
+ assert('chain_record is absent from the DECIDE catalog') { !catalog.include?('**chain_record**') }
482
+ assert('llm_configure is absent from the DECIDE catalog') { !catalog.include?('**llm_configure**') }
483
+ assert('an ordinary tool is present in the DECIDE catalog') { catalog.include?('**knowledge_get**') }
484
+
485
+ Dir.chdir(ORIG_PWD)
486
+ FileUtils.rm_rf(TMPDIR)
487
+ puts "\n#{'=' * 60}\nRESULT: #{$pass} passed, #{$fail} failed\n#{'=' * 60}"
488
+ exit($fail.zero? ? 0 : 1)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'digest'
5
+ require 'yaml'
5
6
  require_relative '../lib/agent'
6
7
 
7
8
  module KairosMcp
@@ -836,6 +837,121 @@ module KairosMcp
836
837
  act_result['summary'] = 'failed'
837
838
  end
838
839
 
840
+ # Protected directories and the roots a tool may resolve a relative
841
+ # path against, for the pre-ACT protected-path check. The roots
842
+ # include the tools' own defaults (@safety's workspace and safe
843
+ # roots — the latter is the data dir when safety.yml sets none), so
844
+ # a relative path is judged where the tool would actually write it.
845
+ def stores_dir_for_admission
846
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:data_dir) && KairosMcp.data_dir
847
+ return KairosMcp.data_dir
848
+ end
849
+ File.join(Dir.pwd, '.kairos')
850
+ end
851
+
852
+ def protected_dirs_for_admission
853
+ root = project_root_for_merge
854
+ [stores_dir_for_admission, File.join(root, '.claude'), File.join(root, '.codex')]
855
+ end
856
+
857
+ def workspace_roots_for_admission
858
+ safety_roots = %i[workspace_root safe_root].filter_map do |m|
859
+ @safety.respond_to?(m) ? @safety.public_send(m) : nil
860
+ rescue StandardError
861
+ nil
862
+ end
863
+ (safety_roots + [ENV['KAIROS_WORKSPACE'], Dir.pwd, project_root_for_merge]).compact.map(&:to_s).uniq
864
+ end
865
+
866
+ # Arguments forced on steps that launch an LLM body. Returns
867
+ # [copy, rewritten_step_ids]; the recorded decision is left as the
868
+ # plan's author wrote it.
869
+ LAUNCHER_ARGS = {
870
+ 'llm_call' => { 'sandbox_mode' => true }
871
+ }.freeze
872
+
873
+ def confine_launcher_steps(task_json)
874
+ return [task_json, []] unless task_json.is_a?(Hash) && task_json['steps'].is_a?(Array)
875
+
876
+ copy = JSON.parse(JSON.generate(task_json))
877
+ rewritten = []
878
+ copy['steps'].each do |step|
879
+ forced = step.is_a?(Hash) ? LAUNCHER_ARGS[step['tool_name'].to_s] : nil
880
+ next unless forced
881
+
882
+ step['tool_arguments'] = (step['tool_arguments'].is_a?(Hash) ? step['tool_arguments'] : {}).merge(forced)
883
+ rewritten << step['step_id'].to_s
884
+ end
885
+ [copy, rewritten]
886
+ end
887
+
888
+ # Where an act may touch anything at all: the project (and an
889
+ # explicitly configured workspace). The home directory or the
890
+ # filesystem root never counts as a project (a global install whose
891
+ # data dir is ~/.kairos would otherwise allow ~/.zshrc and ~/.ssh);
892
+ # with no project left, nothing is allowed and the act is refused.
893
+ def allowed_roots_for_admission
894
+ home = begin
895
+ Dir.home
896
+ rescue StandardError
897
+ nil
898
+ end
899
+ too_broad = [home, '/'].compact.map { |d| ::KairosMcp::SkillSets::Agent::Admission.safe_canonical(d) }
900
+ [project_root_for_merge, ENV['KAIROS_WORKSPACE']].compact.map(&:to_s).reject(&:empty?)
901
+ .reject { |d| too_broad.include?(::KairosMcp::SkillSets::Agent::Admission.safe_canonical(d)) }
902
+ end
903
+
904
+ # The instance's default llm_call provider, read from llm_client's
905
+ # config; nil when unreadable (steps without a safe override are then
906
+ # refused).
907
+ # Read the way llm_call reads it (llm_call.rb load_config): Symbol
908
+ # permitted, and 'anthropic' when the file does not exist.
909
+ def configured_llm_provider
910
+ path = File.join(stores_dir_for_admission, 'skillsets', 'llm_client', 'config', 'llm_client.yml')
911
+ return 'anthropic' unless File.exist?(path)
912
+
913
+ (YAML.safe_load(File.read(path), permitted_classes: [Symbol]) || {})['provider']&.to_s
914
+ rescue StandardError
915
+ nil
916
+ end
917
+
918
+ # When the file tools' own root is inside the stores (safety.yml sets
919
+ # no safe_root), every relative path lands there and is refused; say so.
920
+ def stores_root_hint
921
+ stores = ::KairosMcp::SkillSets::Agent::Admission.safe_canonical(stores_dir_for_admission).downcase
922
+ inside = workspace_roots_for_admission.any? do |r|
923
+ c = ::KairosMcp::SkillSets::Agent::Admission.safe_canonical(r).downcase
924
+ c == stores || c.start_with?("#{stores}/")
925
+ end
926
+ inside ? ' (this instance resolves tool paths inside the KairosChain stores: set safe_root in safety.yml)' : ''
927
+ rescue StandardError
928
+ ''
929
+ end
930
+
931
+ # A refused act is a security-relevant event: record it on the chain
932
+ # (driver context) whether or not the guard is on. Non-blocking —
933
+ # the refusal stands even if the record does not land.
934
+ def record_act_refusal(session, violations)
935
+ record = {
936
+ 'kind' => 'agent_act_refused',
937
+ 'session_id' => session.session_id,
938
+ 'mandate_id' => session.mandate_id,
939
+ 'cycle' => session.cycle_number + 1,
940
+ 'reason' => violations.map { |v| v['rule'] }.compact.uniq,
941
+ 'steps' => violations.first(10).map { |v|
942
+ { 'step_id' => v['step_id'].to_s[0, 40], 'tool_name' => v['tool_name'].to_s[0, 60], 'rule' => v['rule'] }
943
+ },
944
+ 'recorded_at' => Time.now.utc.iso8601
945
+ }
946
+ out = invoke_tool('chain_record', { 'logs' => [JSON.generate(record)] })
947
+ text = Array(out).map { |b| b[:text] || b['text'] }.compact.join
948
+ unless text.match?(/Block #\d+ recorded successfully/)
949
+ log_agent(:warn, 'act_refusal_unrecorded', session, detail: text[0, 160])
950
+ end
951
+ rescue StandardError => e
952
+ log_agent(:warn, 'act_refusal_unrecorded', session, error: e.message)
953
+ end
954
+
839
955
  def project_root_for_merge
840
956
  if defined?(KairosMcp) && KairosMcp.respond_to?(:data_dir)
841
957
  root = File.dirname(KairosMcp.data_dir)
@@ -1634,13 +1750,49 @@ module KairosMcp
1634
1750
  admission_denied = guard_admission_blacklist(session)
1635
1751
  return admission_denied if admission_denied.is_a?(Hash) # guard halt
1636
1752
 
1753
+ # What constrains the agent or its operator is off limits to the act,
1754
+ # with or without the guard: a plan whose path arguments reach the
1755
+ # stores, the harness configuration or the instruction files is
1756
+ # refused before it runs (Admission::PROTECTED_*). This binds path
1757
+ # arguments only; store writers that carry content rather than a
1758
+ # path (context_save, operator_report, ...) remain the act's tools
1759
+ # by design.
1760
+ admission = ::KairosMcp::SkillSets::Agent::Admission
1761
+ violations = admission.protected_path_violations(decision_payload['task_json'],
1762
+ protected_dirs_for_admission,
1763
+ workspace_roots_for_admission,
1764
+ allowed_roots_for_admission) +
1765
+ admission.unsafe_llm_steps(decision_payload['task_json'], configured_llm_provider)
1766
+ unless violations.empty?
1767
+ listed = violations.map { |v| "#{v['step_id']} #{v['tool_name']} [#{v['rule']}]" }.join('; ')
1768
+ log_agent(:warn, 'act_protected_path_refused', session, steps: listed[0, 200])
1769
+ record_act_refusal(session, violations)
1770
+ return { 'error' => 'ACT refused before execution: the plan names a protected location ' \
1771
+ '(KairosChain stores, harness configuration, instruction files), a ' \
1772
+ 'path outside the project, or an llm_call provider that carries tools, ' \
1773
+ "in #{listed[0, 240]}#{stores_root_hint}",
1774
+ 'summary' => 'failed', 'protected_path_refused' => violations }
1775
+ end
1776
+
1777
+ # Denied on the act route whether or not the guard is on: record-store
1778
+ # writers (the record judges the act, so the act may not write it)
1779
+ # and configuration writers (they rewrite what governs every later
1780
+ # call without naming a path, or launch an external agent body).
1637
1781
  act_ctx = session.invocation_context.derive(
1638
1782
  blacklist_remove: %w[autoexec_plan autoexec_run],
1639
- blacklist_add: admission_denied
1783
+ blacklist_add: (admission::RECORD_STORE_TOOLS + admission::ACT_CONFIG_WRITERS + admission_denied).uniq
1640
1784
  )
1641
1785
 
1786
+ # LLM bodies the act launches run without tools in the project root:
1787
+ # llm_call steps are sandboxed (providers that carry tools were
1788
+ # refused above; multi_llm_review is denied on the act context). The
1789
+ # executed plan is a copy; the rewritten step ids are logged so the
1790
+ # recorded decision and the executed plan hash can be reconciled.
1791
+ task_json, rewritten = confine_launcher_steps(decision_payload['task_json'])
1792
+ log_agent(:info, 'act_launcher_steps_confined', session, steps: rewritten.join(',')) unless rewritten.empty?
1793
+
1642
1794
  plan_result = invoke_tool('autoexec_plan', {
1643
- 'task_json' => JSON.generate(decision_payload['task_json'])
1795
+ 'task_json' => JSON.generate(task_json)
1644
1796
  }, context: act_ctx)
1645
1797
 
1646
1798
  plan_parsed = JSON.parse(plan_result.map { |b| b[:text] || b['text'] }.compact.join)
@@ -2795,7 +2947,12 @@ module KairosMcp
2795
2947
  "arguments, so a body written as an instruction — assemble this, summarize " \
2796
2948
  "that — is delivered to the operator verbatim as the report. When the " \
2797
2949
  "deliverable depends on outputs of steps in this plan, deliver it in the " \
2798
- "next cycle, written from what those steps actually returned."
2950
+ "next cycle, written from what those steps actually returned.\n" \
2951
+ " (h) Some places are never the act's to touch, in any argument of any step: " \
2952
+ "the KairosChain stores (.kairos), .claude, .codex, .mcp.json, CLAUDE.md, AGENTS.md, " \
2953
+ "and anything outside the project. A plan naming one is refused before it runs. " \
2954
+ "Configuration tools (llm_configure, mode_hooks_*, plugin_project), multi_llm_review and " \
2955
+ "llm_call providers that carry tools (codex, cursor) are not the act's either."
2799
2956
  end
2800
2957
 
2801
2958
  def reflect_system_prompt
@@ -2982,6 +3139,11 @@ module KairosMcp
2982
3139
  ctx = session&.invocation_context
2983
3140
  tools = @registry.list_tools
2984
3141
  tools = tools.reject { |t| ctx && !ctx.allowed?(t[:name]) } if ctx
3142
+ # The act route always refuses record-store writers; offering them
3143
+ # to the planner would only produce plans that fail at dispatch.
3144
+ adm = ::KairosMcp::SkillSets::Agent::Admission
3145
+ act_denied = adm::RECORD_STORE_TOOLS + adm::ACT_CONFIG_WRITERS
3146
+ tools = tools.reject { |t| act_denied.any? { |pat| File.fnmatch(pat, t[:name].to_s) } }
2985
3147
 
2986
3148
  tools.map { |t|
2987
3149
  format_tool_entry(t)
@@ -128,10 +128,15 @@ module KairosMcp
128
128
  'role' => 'user',
129
129
  'content' => build_user_prompt(section_name, instructions, context_text, max_words, language)
130
130
  }]
131
+ # A section writer only produces text from what it is given. Sandboxed
132
+ # so a claude_code provider does not launch `claude -p` in the project
133
+ # root with the project's instruction files and permission rules —
134
+ # where instructions inside the section request could run commands.
131
135
  llm_args = {
132
136
  'messages' => messages,
133
137
  'system' => system_prompt,
134
- 'max_tokens' => resolve_max_tokens(max_words, context_text)
138
+ 'max_tokens' => resolve_max_tokens(max_words, context_text),
139
+ 'sandbox_mode' => true
135
140
  }
136
141
  # Forward InvocationContext via dispatch-level context: keyword only.
137
142
  result = @caller.invoke_tool('llm_call', llm_args, context: invocation_context)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
5
+ require 'tmpdir'
6
+ require 'fileutils'
5
7
  require_relative 'adapter'
6
8
  require_relative 'safe_subprocess'
7
9
 
@@ -14,12 +16,19 @@ module KairosMcp
14
16
  class CursorAdapter < Adapter
15
17
  DEFAULT_TIMEOUT = 180
16
18
 
19
+ # `agent -p` has every tool, write and shell included, and runs in its
20
+ # working directory. Launched from the MCP server it ran in the project
21
+ # root, so a prompt could have it edit or execute there. A seat here
22
+ # only answers: read-only plan mode, in an empty directory made for
23
+ # the call (--trust only skips the trust prompt for that empty dir).
24
+ BASE_ARGS = ['agent', '-p', '--trust', '--mode', 'plan'].freeze
25
+
17
26
  def call(messages:, system: nil, tools: nil, model: nil,
18
27
  max_tokens: nil, temperature: nil, output_schema: nil)
19
28
  prompt = build_prompt(messages, system, tools, output_schema)
20
29
  timeout_seconds = @config&.dig('timeout_seconds') || DEFAULT_TIMEOUT
21
30
 
22
- args = ['agent', '-p']
31
+ args = BASE_ARGS.dup
23
32
 
24
33
  # Multi-model support: pass --model to `agent -p` when caller specified
25
34
  # a model. Backward compat: nil/empty → no flag → cursor's default
@@ -30,13 +39,19 @@ module KairosMcp
30
39
  args << '--model' << model.to_s
31
40
  end
32
41
 
33
- stdout, stderr, status = SafeSubprocess.safe_capture(
34
- args,
35
- stdin_data: prompt,
36
- timeout_seconds: timeout_seconds,
37
- env: {},
38
- dispatch_id: @config&.dig('dispatch_id')
39
- )
42
+ workdir = Dir.mktmpdir('kairos_cursor_')
43
+ stdout, stderr, status = begin
44
+ SafeSubprocess.safe_capture(
45
+ args,
46
+ stdin_data: prompt,
47
+ timeout_seconds: timeout_seconds,
48
+ env: {},
49
+ dispatch_id: @config&.dig('dispatch_id'),
50
+ chdir: workdir
51
+ )
52
+ ensure
53
+ FileUtils.remove_entry(workdir, true)
54
+ end
40
55
 
41
56
  unless status && status.success?
42
57
  msg = strip_ansi(stderr)[0..200]
@@ -39,7 +39,7 @@ module KairosMcp
39
39
  stub_capture_recording_args
40
40
  begin
41
41
  @adapter.call(messages: [{ 'role' => 'user', 'content' => 'hi' }], model: nil)
42
- assert_equal ['agent', '-p'], @captured_args
42
+ assert_equal CursorAdapter::BASE_ARGS, @captured_args
43
43
  refute_includes @captured_args, '--model'
44
44
  ensure
45
45
  restore_capture
@@ -50,7 +50,7 @@ module KairosMcp
50
50
  stub_capture_recording_args
51
51
  begin
52
52
  @adapter.call(messages: [{ 'role' => 'user', 'content' => 'hi' }])
53
- assert_equal ['agent', '-p'], @captured_args
53
+ assert_equal CursorAdapter::BASE_ARGS, @captured_args
54
54
  ensure
55
55
  restore_capture
56
56
  end
@@ -60,7 +60,7 @@ module KairosMcp
60
60
  stub_capture_recording_args
61
61
  begin
62
62
  @adapter.call(messages: [{ 'role' => 'user', 'content' => 'hi' }], model: '')
63
- assert_equal ['agent', '-p'], @captured_args
63
+ assert_equal CursorAdapter::BASE_ARGS, @captured_args
64
64
  ensure
65
65
  restore_capture
66
66
  end
@@ -80,7 +80,7 @@ module KairosMcp
80
80
  stub_capture_recording_args
81
81
  begin
82
82
  @adapter.call(messages: [{ 'role' => 'user', 'content' => 'hi' }], model: 'gpt-5.4-high')
83
- assert_equal ['agent', '-p', '--model', 'gpt-5.4-high'], @captured_args
83
+ assert_equal CursorAdapter::BASE_ARGS + ['--model', 'gpt-5.4-high'], @captured_args
84
84
  ensure
85
85
  restore_capture
86
86
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ # A cursor seat only answers: `agent -p` has every tool (write and shell
3
+ # included), so it runs read-only (--mode plan) in an empty directory made for
4
+ # the call and removed afterwards, never in the MCP server's working directory.
5
+
6
+ require 'minitest/autorun'
7
+ require_relative '../lib/llm_client/adapter'
8
+ require_relative '../lib/llm_client/cursor_adapter'
9
+
10
+ module KairosMcp
11
+ module SkillSets
12
+ module LlmClient
13
+ class TestCursorAdapterReadonly < Minitest::Test
14
+ Status = Struct.new(:success?, :exitstatus)
15
+
16
+ def test_runs_read_only_in_an_empty_private_directory
17
+ seen = {}
18
+ SafeSubprocess.singleton_class.send(:alias_method, :_orig_safe_capture, :safe_capture)
19
+ SafeSubprocess.singleton_class.send(:define_method, :safe_capture) do |args, **kw|
20
+ seen[:args] = args
21
+ seen[:chdir] = kw[:chdir]
22
+ seen[:entries_at_launch] = Dir.children(kw[:chdir])
23
+ seen[:chdir_real] = File.realpath(kw[:chdir])
24
+ seen[:pwd_real] = File.realpath(Dir.pwd)
25
+ ['{"content":"ok"}', '', Status.new(true, 0)]
26
+ end
27
+ begin
28
+ CursorAdapter.new({ 'timeout_seconds' => 30 }).call(messages: [{ 'role' => 'user', 'content' => 'hi' }])
29
+ ensure
30
+ SafeSubprocess.singleton_class.send(:remove_method, :safe_capture)
31
+ SafeSubprocess.singleton_class.send(:alias_method, :safe_capture, :_orig_safe_capture)
32
+ SafeSubprocess.singleton_class.send(:remove_method, :_orig_safe_capture)
33
+ end
34
+
35
+ assert_includes seen[:args].each_cons(2).to_a, ['--mode', 'plan']
36
+ assert_includes seen[:args], '--trust'
37
+ refute_includes seen[:args], '--force'
38
+ refute_includes seen[:args], '--yolo'
39
+ refute_nil seen[:chdir]
40
+ refute_equal seen[:pwd_real], seen[:chdir_real], 'the seat must not run in the caller\'s working directory'
41
+ assert_empty seen[:entries_at_launch]
42
+ refute File.exist?(seen[:chdir]), 'the per-call directory is removed after the call'
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.88.1
4
+ version: 3.88.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -418,6 +418,7 @@ files:
418
418
  - templates/skillsets/agent/plugin/SKILL.md
419
419
  - templates/skillsets/agent/plugin/agents/monitor.md
420
420
  - templates/skillsets/agent/skillset.json
421
+ - templates/skillsets/agent/test/test_agent_act_hardening.rb
421
422
  - templates/skillsets/agent/test/test_agent_admission.rb
422
423
  - templates/skillsets/agent/test/test_agent_capability_discovery.rb
423
424
  - templates/skillsets/agent/test/test_agent_complexity_review.rb
@@ -651,6 +652,7 @@ files:
651
652
  - templates/skillsets/llm_client/test/test_call_router.rb
652
653
  - templates/skillsets/llm_client/test/test_claude_code_adapter_parse.rb
653
654
  - templates/skillsets/llm_client/test/test_cursor_adapter_model.rb
655
+ - templates/skillsets/llm_client/test/test_cursor_adapter_readonly.rb
654
656
  - templates/skillsets/llm_client/test/test_cursor_adapter_retryable.rb
655
657
  - templates/skillsets/llm_client/test/test_error_taxonomy.rb
656
658
  - templates/skillsets/llm_client/tools/llm_call.rb