kairos-chain 3.88.0 → 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: 6ab07b57ae1574d18326409b3db66f61dabd697c9bc8d52e6ba20b00ee078bb6
4
- data.tar.gz: ad59a3b0ddaca3913faffcd102cf7a361ad4772b90dcd0950c703884f6a8d0b9
3
+ metadata.gz: 8a785289fdb24dc921cad6c727b74168557a7dcf90593cb7ae267d2cb6b3501f
4
+ data.tar.gz: 422bb014ce8b479f765972b4f05fe57f86c71c09bae7c48277e8625b798767af
5
5
  SHA512:
6
- metadata.gz: bed9eb85e62df8c52384e0ead71655d1c44b375abd79cf4dc85797e910644ce8360743073572d12edd52d515c1bae9eecdf524c22811a44c444cca0766839a1c
7
- data.tar.gz: 39d2c4d7b801d8cfb9f73e3f2075e87178d7ea2d2bbbb57dd967b24dbf69f72a3812a4f9782c793bebd7a27525793f144226fb7a00ae6712851ef276ceefee6e
6
+ metadata.gz: b399c8a6e440d323ce30c73fb1c62acbb6fcefe7c67e76b89bed4171a3a9bc57d3af48de2ffb4cfc9ac4a802498dde65a868e0435564824d1f29a638748f3e73
7
+ data.tar.gz: 6b2fc4894c4d52489bb86580386a50121c64ebcdc7f1e363e18813f0a9a548d2a9f974104206fc66d4255b00119cb6f9d4c4b3b81523776a365c4b37be58779b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,108 @@ 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
+
70
+ ## [3.88.1] - 2026-10-06
71
+
72
+ ### Fixed — agent guard: the verdict reaches the record
73
+
74
+ Found in a guarded trial on 3.88.0. The guard judged correctly, but its
75
+ judgment never reached the record: the chain held only autoexec's "execution
76
+ complete" for an act the guard had failed, progress said `completed`, and the
77
+ FAIL survived only in a mutable session file.
78
+
79
+ - **The verdict is chain-recorded.** Right after judging, before any halt
80
+ checkpoint and before any merge, the driver records `kind:
81
+ agent_guard_verdict`: the verdict, the pinned spec hash, the failed checks,
82
+ the planned route, and the act's error if it had one.
83
+ - **A verdict the chain does not take stops the loop.** Whatever the verdict,
84
+ nothing merges and the cycle halts for the operator. The halt reason names
85
+ the lost verdict and its reason, why the record failed, and whether the act
86
+ already took effect (in-process) or stays quarantined (confined). Responses
87
+ and the progress `guard_record` carry `verdict_recorded: false` and
88
+ `lost_verdict`. On a backend whose chain cannot be appended to (sqlite and
89
+ postgresql do not declare a ledger path), every guarded cycle now halts
90
+ this way instead of running unrecorded.
91
+ - **Progress and responses follow the verdict.** A failed verdict is recorded
92
+ and reported as `failed`, not `completed`; REFLECT is shown the verdict, and
93
+ the next cycle's executor context says the cycle failed. Manual responses
94
+ carry `guard` (verdict, failed checks) and `act_error`. The manual risk-resume
95
+ path reports a guard halt as `guard_halt`, not `ok`. Autonomous responses
96
+ carry the halt reason and, per cycle, `guard_verdict`, `guard_reason` and
97
+ `verdict_recorded`; steps set aside for a person are still listed when a
98
+ cycle halts. A result carrying only an error is no longer reported as
99
+ `completed`.
100
+
101
+ Not fixed here: the confined route still cannot run. `agent_execute` is not
102
+ registered by the agent SkillSet, so a plan naming a file tool fails at ACT
103
+ (with the guard on, that FAIL is now recorded), and a file acceptance check
104
+ cannot PASS in this version. Wiring it is separate work.
105
+
106
+ Also ships `minimum_nomic`'s `bin/run_gm.rb` instance-local changes (a11d103),
107
+ so `system_upgrade` stops reverting them.
108
+
7
109
  ## [3.88.0] - 2026-09-30
8
110
 
9
111
  ### Added — `multi_llm_review` 0.11.1: an artifact named by path and sha256
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.88.0"
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