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 +4 -4
- data/CHANGELOG.md +63 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/lib/agent/admission.rb +200 -0
- data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +9 -0
- data/templates/skillsets/agent/plugin/SKILL.md +9 -2
- data/templates/skillsets/agent/test/test_agent_act_hardening.rb +488 -0
- data/templates/skillsets/agent/tools/agent_step.rb +165 -3
- data/templates/skillsets/document_authoring/lib/document_authoring/section_writer.rb +6 -1
- data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +23 -8
- data/templates/skillsets/llm_client/test/test_cursor_adapter_model.rb +4 -4
- data/templates/skillsets/llm_client/test/test_cursor_adapter_readonly.rb +47 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a785289fdb24dc921cad6c727b74168557a7dcf90593cb7ae267d2cb6b3501f
|
|
4
|
+
data.tar.gz: 422bb014ce8b479f765972b4f05fe57f86c71c09bae7c48277e8625b798767af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
-
|
|
52
|
-
`
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|