kairos-chain 3.88.2 → 3.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a785289fdb24dc921cad6c727b74168557a7dcf90593cb7ae267d2cb6b3501f
4
- data.tar.gz: 422bb014ce8b479f765972b4f05fe57f86c71c09bae7c48277e8625b798767af
3
+ metadata.gz: b1f38307df012164fc2070ff3a5a7489f651c678e4bcb75f4d93ef2ba65dec6d
4
+ data.tar.gz: 11f40f3abe1a502600d451ffb4e6fe757f1cd351fa7f9af7c4c09fe0669f776c
5
5
  SHA512:
6
- metadata.gz: b399c8a6e440d323ce30c73fb1c62acbb6fcefe7c67e76b89bed4171a3a9bc57d3af48de2ffb4cfc9ac4a802498dde65a868e0435564824d1f29a638748f3e73
7
- data.tar.gz: 6b2fc4894c4d52489bb86580386a50121c64ebcdc7f1e363e18813f0a9a548d2a9f974104206fc66d4255b00119cb6f9d4c4b3b81523776a365c4b37be58779b
6
+ metadata.gz: 5886c559503ae2223da85857a13e8fa2b365271f5988c3a24b77675ce56215ca481fb581d60f82f523dfe9a1cce4fb81e8b5cfc70f81fb68beec4142183c4450
7
+ data.tar.gz: de3e9624a91ef9bbc9eaf3712cf64e6367f8b13523c1f3ace774cd31d0c7be4aa5560d60d7f8bfda9197802165dea0ab8a5dd7c74cb2bf666f4c903b26603249
data/CHANGELOG.md CHANGED
@@ -4,6 +4,95 @@ 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.90.0] - 2026-10-07
8
+
9
+ ### Added — agent: every answer is a ruling on the chain, and an answer typed at a terminal binds
10
+
11
+ Until now the gate log kept which answer advanced a session, but not who gave
12
+ it, why, or what it was given to. **Every answer that advances an agent session
13
+ is now recorded on the chain as a ruling** (`agent_answer`) — through
14
+ `agent_step` (approve, revise, skip, stop, adjudicate) and `agent_stop` alike.
15
+
16
+ - **What a ruling holds**: the point it answered (state, cycle, and why the
17
+ session was waiting), the plan's SHA-256 and the driver's own signals when a
18
+ plan was the subject, the act-route table in force, the decision, and whether
19
+ it was attested. A replayed retry writes none. A ruling that fails to record
20
+ never blocks the answer; the response says so under `ruling`. Reasons
21
+ (`rationale`, new optional argument) and revise feedback stay off the chain;
22
+ the ruling carries their SHA-256.
23
+ - **Answer as yourself, at a terminal**:
24
+
25
+ ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
26
+
27
+ It shows why the session stopped and the whole plan (every argument, escaped,
28
+ and the file it is kept in), asks for your answer, an optional reason and a
29
+ nonce, and records the answer bound to that point and that plan. The answer
30
+ then sent through `agent_step` there must be the same one, or it is refused
31
+ (`answer_refused`); **a stop always goes through**. After a revise at the
32
+ terminal, send `revise` without feedback and your text is used. Pass
33
+ `--project-dir` when the MCP server runs in another directory than the
34
+ project holding its data dir. An answer sent only through MCP is recorded as
35
+ the caller's, unattested.
36
+ - **Every waiting session says why it stopped** (`stop` in every response and in
37
+ `agent_status`). `cycle_checkpoint` means only the scheduled end-of-cycle
38
+ stop (Gate 8, or a manual act that succeeded and left nothing over); failed
39
+ acts, set-aside steps, guard halts, adjudications, risk pauses and the rest
40
+ name themselves.
41
+ - **While the chain cannot be read, every answer but a stop is refused**,
42
+ because whether you answered at the terminal cannot be checked. On chain
43
+ stores other than the file ledger (sqlite, postgresql) the agent can only be
44
+ stopped.
45
+
46
+ No action is needed after upgrading. Approval delegation (the shadow judge) is
47
+ not part of this release; do not run the agent unattended.
48
+
49
+ ## [3.89.0] - 2026-10-07
50
+
51
+ ### Security — agent: the act route runs from an allow-list
52
+
53
+ The 3.88.x act route refused tools by name, and every review round found
54
+ another tool, route or path nobody had named. **The act route now runs a step
55
+ only if the act-route table classifies its tool.** Every other step is set
56
+ aside — with the steps that depend on it — and handed to the operator as
57
+ `awaiting_operator` at the end of the cycle, in manual and autonomous mode.
58
+
59
+ **After upgrading, the act route runs nothing until you rule the table into
60
+ force, at a terminal:**
61
+
62
+ ruby .kairos/skillsets/agent/bin/agent_rule.rb status
63
+ ruby .kairos/skillsets/agent/bin/agent_rule.rb activate
64
+
65
+ `activate` shows the tools and asks you to type back a nonce; it refuses to run
66
+ without a terminal. The ruling goes on the chain. The table
67
+ (`agent/lib/agent/act_classification.yml`) is in force only while the latest
68
+ ruling names the SHA-256 of its exact bytes, so an upgrade that changes it, a
69
+ `withdraw`, or an edit empties the act route again until you rule. Every agent
70
+ response says under `classification` whether the table is in force.
71
+
72
+ - **Base table**: read-only tools, `context_save`, `operator_report`,
73
+ sandboxed `llm_call`, and `safe_file_write` / `safe_file_edit` /
74
+ `safe_file_copy`.
75
+ - **Work-tree writes are allowed by place**: a plain relative path under
76
+ `docs/drafts/`, a file named `draft_*` ending `.md` or `.txt`, no hidden
77
+ segment and no `workspace_root` argument — checked as written and as
78
+ resolved. Any other write is set aside. With the guard on, every work-tree
79
+ write is set aside.
80
+ - **No step may name a hidden (dot) path, a leading `~`, a control character,
81
+ `keys/` or a private-key file** (`.pem`, `.key`, `.p12`, `.pfx`, `id_rsa`, …),
82
+ reads included; `.git`, `.github`, `.vscode`, `.cursor`, `.gemini` and
83
+ `.envrc` join the protected locations.
84
+ - **The file route (`agent_execute`) is no longer taken.** Its tools cannot be
85
+ classified.
86
+ - **Risk**: the risk gate and the act read the same classified plan. A step's
87
+ risk is the table's, raised (never lowered) by the plan's own label or
88
+ `risk_default`; an unknown label counts as high.
89
+ - DECIDE is told which tools run and where writes may land.
90
+
91
+ Still open: secret files with visible names (e.g. `credentials.json`) are
92
+ readable by `safe_file_read`; a read-place allow-list needs per-instance table
93
+ extensions and comes later. Do not run the agent unattended until approval
94
+ delegation lands.
95
+
7
96
  ## [3.88.2] - 2026-10-06
8
97
 
9
98
  ### Security — agent: the act and its LLM bodies stay inside their gates
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.88.2"
2
+ VERSION = "3.90.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # The operator's terminal rulings for the agent (design v0.3, INV-A2 / INV-D8):
5
+ # the act-route allow-list, and answers at a stopped session.
6
+ #
7
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb activate # rule the current table into force
8
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb withdraw # take it out of force
9
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb status # show what is in force (records nothing)
10
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb answer SESSION_ID
11
+ # # answer a stopped session; the MCP answer there must then match
12
+ #
13
+ # Options: --data-dir DIR the instance data dir whose chain is written
14
+ # (default: the .kairos this script is installed in)
15
+ # --project-dir DIR answer only: the directory the MCP server runs in,
16
+ # whose .kairos/storage holds its agent sessions
17
+ # (default: the directory holding the data dir).
18
+ # Needed when the server runs with --data-dir
19
+ # pointing at another project's .kairos.
20
+ #
21
+ # activate, withdraw and answer read a nonce typed back at /dev/tty and refuse to run
22
+ # without a terminal. That keeps an MCP call from producing a ruling through
23
+ # this path. It does not prove a person typed: any process that can start
24
+ # programs can open a pseudo-terminal, read the nonce and type it back (shown
25
+ # in review, 2026-10-06), and such a process can write the chain directly
26
+ # anyway. 'attested' therefore records the path the ruling came by, not who
27
+ # was at the keyboard (INV-D8 boundary; the constrained party is the agent).
28
+
29
+ require 'json'
30
+
31
+ action = ARGV.find { |a| %w[activate withdraw status answer].include?(a) }
32
+ abort 'usage: agent_rule.rb activate|withdraw|status|answer SESSION_ID [--data-dir DIR]' unless action
33
+ session_id = action == 'answer' ? ARGV[ARGV.index('answer') + 1].to_s : nil
34
+ abort 'usage: agent_rule.rb answer SESSION_ID [--data-dir DIR]' if session_id&.then { |s| s.empty? || s.start_with?('-') }
35
+
36
+ idx = ARGV.index('--data-dir')
37
+ data_dir = idx ? File.expand_path(ARGV[idx + 1].to_s) : File.expand_path('../../..', __dir__)
38
+ abort "no data dir at #{data_dir}" unless File.directory?(data_dir)
39
+
40
+ $LOAD_PATH.unshift(ENV['KAIROS_SERVER_LIB']) if ENV['KAIROS_SERVER_LIB']
41
+ begin
42
+ require 'kairos_mcp'
43
+ require 'kairos_mcp/kairos_chain/chain'
44
+ rescue LoadError => e
45
+ abort "cannot load kairos_mcp (#{e.message}); install the kairos-chain gem or set KAIROS_SERVER_LIB"
46
+ end
47
+ KairosMcp.data_dir = data_dir
48
+
49
+ lib = File.expand_path('../lib', __dir__)
50
+ require File.join(lib, 'agent', 'admission')
51
+ require File.join(lib, 'agent', 'mandate_adapter')
52
+ require File.join(lib, 'agent', 'act_classification')
53
+ ac = KairosMcp::SkillSets::Agent::ActClassification
54
+ path = ac::BASE_PATH
55
+
56
+ puts "Data dir: #{data_dir}"
57
+ if action == 'answer'
58
+ require 'kairos_mcp/invocation_context'
59
+ require File.join(lib, 'agent', 'session')
60
+ require File.join(lib, 'agent', 'advance_gate')
61
+ require File.join(lib, 'agent', 'answer_ruling')
62
+ # The server keeps agent sessions under <its working directory>/.kairos/storage
63
+ # (Session.storage_path without Autonomos loaded), and its chain under its
64
+ # data dir. The two can differ; the answer is read from the first and
65
+ # recorded on the second, the chain the server reads.
66
+ pidx = ARGV.index('--project-dir')
67
+ abort 'usage: agent_rule.rb answer SESSION_ID --project-dir DIR' if pidx && ARGV[pidx + 1].to_s.then { |v| v.empty? || v.start_with?('-') }
68
+ project_dir = pidx ? File.expand_path(ARGV[pidx + 1].to_s) : File.dirname(data_dir)
69
+ abort "no project dir at #{project_dir}" unless File.directory?(project_dir)
70
+ Dir.chdir(project_dir)
71
+ session = KairosMcp::SkillSets::Agent::Session.load(session_id)
72
+ unless session
73
+ abort "no agent session #{session_id} under #{File.join(Dir.pwd, '.kairos', 'storage', 'agent_sessions')} " \
74
+ '(if the MCP server runs in another directory, pass --project-dir)'
75
+ end
76
+ puts "Sessions from: #{project_dir}"
77
+ end
78
+ if action == 'status'
79
+ eff = ac.load_effective(path: path)
80
+ puts "Table #{ac::TABLE_ID}: #{eff['status']}"
81
+ puts " file sha256: #{eff['sha256']}"
82
+ puts " ruled sha256: #{eff['ruled_sha256'] || '(none)'}"
83
+ puts " detail: #{eff['detail']}" if eff['detail']
84
+ puts " tools in force: #{eff['tools'].keys.sort.join(', ')}" unless eff['tools'].empty?
85
+ exit 0
86
+ end
87
+
88
+ tty = begin
89
+ File.open('/dev/tty', 'r+')
90
+ rescue StandardError => e
91
+ abort "#{action} needs a terminal (/dev/tty: #{e.class}). Run it yourself in a terminal."
92
+ end
93
+
94
+ begin
95
+ result = if action == 'answer'
96
+ KairosMcp::SkillSets::Agent::AnswerRuling.interactive_answer(
97
+ session: session, gate: KairosMcp::SkillSets::Agent::AdvanceGate.new(session.guard_dir),
98
+ tty_in: tty, tty_out: tty, reload: -> { KairosMcp::SkillSets::Agent::Session.load(session_id) }
99
+ )
100
+ else
101
+ ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
102
+ end
103
+ ensure
104
+ tty.close
105
+ end
106
+ exit(result['recorded'] ? 0 : 1)
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'digest'
6
+ require 'securerandom'
7
+ require 'time'
8
+
9
+ module KairosMcp
10
+ module SkillSets
11
+ module Agent
12
+ # The act route's allow-list (design v0.3, INV-A1 / INV-A2).
13
+ #
14
+ # A step runs on the act route only if its tool is in the effective
15
+ # classification table. Every other step is set aside for the operator,
16
+ # the way a step marked requires_human_cognition is.
17
+ #
18
+ # The table is a norm, so it is in force only by an operator act: the
19
+ # latest attested ruling for the table must name the sha256 of the file's
20
+ # bytes. A missing ruling, a withdrawal, a mismatch, or a table that fails
21
+ # validation all yield the empty table — nothing classified, every step
22
+ # to the operator. This includes the base table shipped with the gem: an
23
+ # upgrade that changes its bytes leaves the act route empty until the
24
+ # operator rules on the new hash.
25
+ #
26
+ # Attestation boundary (INV-D8): a ruling is attested when it was entered
27
+ # at the operator's terminal (see .interactive_rule). Any party that can
28
+ # write the chain or the stores — an MCP caller, the calling session, a
29
+ # shell — could write a block of the same shape. The constrained party is
30
+ # the agent, which cannot: its act route denies record-store writers and
31
+ # protected locations, and the file route is not reachable.
32
+ module ActClassification
33
+ TABLE_ID = 'act_classification'
34
+ RULING_KIND = 'agent_table_ruling'
35
+ BASE_PATH = File.expand_path('act_classification.yml', __dir__)
36
+
37
+ # Effect classes a table may admit, and the risk each carries. Anything
38
+ # else (L0, record-store, irreversible, outward) is not admissible.
39
+ EFFECT_RISK = {
40
+ 'read_only' => 'low',
41
+ 'session_record' => 'low',
42
+ 'llm_sandboxed' => 'low',
43
+ 'reversible_local' => 'medium'
44
+ }.freeze
45
+
46
+ # Effects that land in the live tree. Under the guard those belong to
47
+ # the confined route (AGT-1), which is not wired, so they are set aside.
48
+ # Without the guard they are classified only for a step whose every
49
+ # location lands inside the table's write_roots with one of its
50
+ # write_extensions (design v0.3 §6.5 as ruled 2026-10-06): writes are
51
+ # allowed by place, because refusing by place — naming each location
52
+ # a tool executes — did not close (implementation review R1 found an
53
+ # editor add-on path the name list missed).
54
+ LIVE_TREE_EFFECTS = %w[reversible_local].freeze
55
+
56
+ RISK_ORDER = %w[low medium high].freeze
57
+
58
+ # Names a table may never admit, whatever their class: the tools the
59
+ # 3.88.2 deny-lists refuse, the L0/L1 store writers, and the file
60
+ # route's tools (classification precedes routing: no route is reachable
61
+ # through a tool this table cannot name).
62
+ def self.forbidden_patterns
63
+ adm = ::KairosMcp::SkillSets::Agent::Admission
64
+ adm::RECORD_STORE_TOOLS + adm::ACT_CONFIG_WRITERS + adm::LAYER_WRITE_TOOLS.values.flatten +
65
+ ::KairosMcp::SkillSets::Agent::MandateAdapter::FILE_TOOL_NAMES
66
+ end
67
+
68
+ module_function
69
+
70
+ def sha256_of(path)
71
+ Digest::SHA256.hexdigest(File.binread(path))
72
+ end
73
+
74
+ # Ruling records in chain order, from blocks whose data is an array of
75
+ # strings. Entries that are not JSON objects of the ruling kind are
76
+ # skipped.
77
+ def rulings_from_blocks(blocks)
78
+ Array(blocks).flat_map do |block|
79
+ data = block.respond_to?(:data) ? block.data : (block['data'] || block[:data])
80
+ Array(data).filter_map do |entry|
81
+ rec = entry.is_a?(String) ? (JSON.parse(entry) rescue nil) : entry
82
+ rec if rec.is_a?(Hash) && rec['kind'] == RULING_KIND
83
+ end
84
+ end
85
+ end
86
+
87
+ # Where rulings are read from. nil in production (the instance chain).
88
+ # Tests set a callable returning [rulings, error], built from blocks the
89
+ # real interactive_rule appended to an in-memory chain.
90
+ class << self
91
+ attr_accessor :rulings_source
92
+ end
93
+
94
+ # [rulings, nil] from the instance chain, or [[], reason] when the
95
+ # chain cannot be read (which empties the table).
96
+ def chain_rulings
97
+ return ActClassification.rulings_source.call if ActClassification.rulings_source
98
+
99
+ require 'kairos_mcp/kairos_chain/chain'
100
+ chain = ::KairosMcp::KairosChain::Chain.new
101
+ state = chain.load_state
102
+ return [[], "chain #{state}"] unless %i[readable absent].include?(state)
103
+
104
+ [rulings_from_blocks(chain.chain), nil]
105
+ rescue StandardError, ScriptError => e
106
+ [[], "chain unreadable: #{e.class}: #{e.message[0, 120]}"]
107
+ end
108
+
109
+ # The effective table: { 'status', 'sha256', 'ruled_sha256', 'tools', 'detail' }.
110
+ # status is 'in_force' only when every condition holds; otherwise tools
111
+ # is empty and status names the first condition that failed.
112
+ def load_effective(path: BASE_PATH, rulings: nil, rulings_error: nil)
113
+ rulings, rulings_error = chain_rulings if rulings.nil? && rulings_error.nil?
114
+ raw = begin
115
+ File.binread(path)
116
+ rescue StandardError => e
117
+ return empty('table_unreadable', nil, detail: "#{e.class}: #{e.message[0, 120]}")
118
+ end
119
+ sha = Digest::SHA256.hexdigest(raw)
120
+ return empty('chain_unreadable', sha, detail: rulings_error) if rulings_error
121
+
122
+ latest = Array(rulings).select { |r| r['table'] == TABLE_ID && r['attested'] == true }.last
123
+ return empty('no_ruling', sha) unless latest
124
+ return empty('withdrawn', sha, ruling: latest) if latest['action'] == 'withdraw'
125
+ return empty('invalid_ruling', sha, ruling: latest) unless latest['action'] == 'activate'
126
+ return empty('hash_mismatch', sha, ruling: latest) unless latest['sha256'] == sha
127
+
128
+ table, error = parse_table(raw)
129
+ return empty('invalid', sha, ruling: latest, detail: error) if error
130
+
131
+ table.merge('status' => 'in_force', 'sha256' => sha, 'ruled_sha256' => latest['sha256'],
132
+ 'ruled_at' => latest['ruled_at'])
133
+ end
134
+
135
+ def empty(status, sha, ruling: nil, detail: nil)
136
+ out = { 'status' => status, 'sha256' => sha, 'tools' => {}, 'write_roots' => [], 'write_extensions' => [],
137
+ 'write_name_prefix' => nil }
138
+ out['ruled_sha256'] = ruling['sha256'] if ruling
139
+ out['detail'] = detail if detail
140
+ out
141
+ end
142
+
143
+ # [table, nil] or [nil, error]. table: { 'tools' => { name => { 'effect',
144
+ # 'risk', 'locations' } }, 'write_roots' => [relative dirs],
145
+ # 'write_extensions' => ['.md', ...] }.
146
+ def parse_table(raw)
147
+ doc = YAML.safe_load(raw)
148
+ return [nil, 'not a mapping with a tools mapping'] unless doc.is_a?(Hash) && doc['tools'].is_a?(Hash)
149
+
150
+ forbidden = forbidden_patterns
151
+ tools = {}
152
+ doc['tools'].each do |name, row|
153
+ name = name.to_s
154
+ return [nil, "row #{name}: not a mapping"] unless row.is_a?(Hash)
155
+
156
+ effect = row['effect'].to_s
157
+ return [nil, "row #{name}: effect #{effect.inspect} is not admissible"] unless EFFECT_RISK.key?(effect)
158
+ if forbidden.any? { |pat| File.fnmatch(pat, name) }
159
+ return [nil, "row #{name}: refused by the act-route deny-lists"]
160
+ end
161
+
162
+ locations = row['locations'] || []
163
+ unless locations.is_a?(Array) && locations.all? { |l| l.is_a?(String) && !l.empty? }
164
+ return [nil, "row #{name}: locations must be a list of argument names"]
165
+ end
166
+
167
+ tools[name] = { 'effect' => effect, 'risk' => EFFECT_RISK[effect], 'locations' => locations }
168
+ end
169
+
170
+ roots = doc['write_roots'] || []
171
+ exts = doc['write_extensions'] || []
172
+ return [nil, 'write_roots must be a list of directories'] unless roots.is_a?(Array) && roots.all?(String)
173
+ return [nil, 'write_extensions must be a list such as .md'] unless exts.is_a?(Array) && exts.all?(String)
174
+
175
+ adm = ::KairosMcp::SkillSets::Agent::Admission
176
+ roots = roots.map { |r| r.strip.sub(%r{/+\z}, '') }
177
+ bad_root = roots.find do |r|
178
+ parts = r.split(%r{[/\\]})
179
+ r.empty? || r.match?(/[[:cntrl:]\\]/) || r.start_with?('/', '~') || parts.include?('..') ||
180
+ parts.include?('.') || parts.any?(&:empty?) ||
181
+ adm.protected_name?(r)
182
+ end
183
+ return [nil, "write_roots: #{bad_root.inspect} must be a relative directory inside the project"] if bad_root
184
+
185
+ exts = exts.map { |e| e.strip.downcase }
186
+ bad_ext = exts.find { |e| !e.match?(/\A\.[a-z0-9]+\z/) }
187
+ return [nil, "write_extensions: #{bad_ext.inspect} is not an extension such as .md"] if bad_ext
188
+
189
+ prefix = doc['write_name_prefix']
190
+ unless prefix.nil? || (prefix.is_a?(String) && prefix.match?(/\A[a-z0-9][a-z0-9_-]*\z/))
191
+ return [nil, "write_name_prefix: #{prefix.inspect} is not a plain prefix such as draft_"]
192
+ end
193
+
194
+ if tools.any? { |_, r| LIVE_TREE_EFFECTS.include?(r['effect']) } && (roots.empty? || exts.empty? || prefix.nil?)
195
+ return [nil, 'a reversible_local row needs write_roots, write_extensions and write_name_prefix']
196
+ end
197
+
198
+ [{ 'tools' => tools, 'write_roots' => roots, 'write_extensions' => exts,
199
+ 'write_name_prefix' => prefix }, nil]
200
+ rescue Psych::Exception => e
201
+ [nil, "YAML: #{e.message[0, 120]}"]
202
+ end
203
+
204
+ # The step's tool row, or nil when the step is not classified for this
205
+ # act. A live-tree effect is classified only without the guard and only
206
+ # when write_scope (a callable from the driver, which knows the roots a
207
+ # tool resolves against) accepts the step; no write_scope, no write.
208
+ def row_for(step, effective, guard: false, write_scope: nil)
209
+ return nil unless step.is_a?(Hash)
210
+
211
+ row = effective['tools'][step['tool_name'].to_s]
212
+ return nil unless row
213
+ if LIVE_TREE_EFFECTS.include?(row['effect'])
214
+ return nil if guard || write_scope.nil? || !write_scope.call(step)
215
+ end
216
+
217
+ row
218
+ end
219
+
220
+ # The higher of the table's risk and the plan's own label: the label may
221
+ # raise a step's risk, never lower it. The label is the step's risk, or
222
+ # the plan's risk_default when the step names none; a label that is not
223
+ # one of low / medium / high (in any case) counts as high.
224
+ def resolved_risk(row, step_risk)
225
+ label = step_risk.to_s.downcase
226
+ own = RISK_ORDER.include?(label) ? label : 'high'
227
+ [row['risk'], own].max_by { |r| RISK_ORDER.index(r) }
228
+ end
229
+
230
+ # [copy, set_aside]. The copy marks every unclassified step for the
231
+ # operator and carries the resolved risk on classified ones. set_aside
232
+ # lists what the driver marked: [{ 'step_id', 'tool_name' }].
233
+ def apply(task_json, effective, guard: false, write_scope: nil)
234
+ copy = JSON.parse(JSON.generate(task_json || {}))
235
+ set_aside = []
236
+ default_risk = copy['meta'].is_a?(Hash) ? copy['meta']['risk_default'] : nil
237
+ Array(copy['steps']).each do |step|
238
+ next unless step.is_a?(Hash)
239
+
240
+ row = row_for(step, effective, guard: guard, write_scope: write_scope)
241
+ if row
242
+ step['risk'] = resolved_risk(row, step['risk'].nil? ? (default_risk || 'low') : step['risk'])
243
+ elsif step['requires_human_cognition'] != true
244
+ step['requires_human_cognition'] = true
245
+ set_aside << { 'step_id' => step['step_id'], 'tool_name' => step['tool_name'] }
246
+ end
247
+ end
248
+ [copy, set_aside]
249
+ end
250
+
251
+ # Values of the step's location arguments as the table names them. A
252
+ # step the table does not classify (or no table in force) falls back to
253
+ # the common names, so the signals do not go quiet without a ruling.
254
+ FALLBACK_LOCATIONS = %w[file_path path source destination].freeze
255
+
256
+ def location_values(step, effective)
257
+ row = effective && effective['tools'][step['tool_name'].to_s]
258
+ names = row ? row['locations'] : FALLBACK_LOCATIONS
259
+ args = step['tool_arguments'].is_a?(Hash) ? step['tool_arguments'] : {}
260
+ names.filter_map { |n| args[n].is_a?(String) ? args[n] : nil }
261
+ end
262
+
263
+ # ---- the terminal ruling (INV-D8) ----
264
+
265
+ NONCE_ALPHABET = %w[a c d e f h j k m n p r t u v w x y 3 4 7 9].freeze
266
+
267
+ def nonce
268
+ Array.new(6) { NONCE_ALPHABET[SecureRandom.random_number(NONCE_ALPHABET.size)] }.join
269
+ end
270
+
271
+ # Shows what is about to be ruled on tty_out, asks for the nonce on
272
+ # tty_in, and appends the ruling to the chain only when the typed nonce
273
+ # matches. action: 'activate' (the file's current bytes) or 'withdraw'.
274
+ # Returns { 'recorded' => bool, ... }.
275
+ def interactive_rule(action:, tty_in:, tty_out:, path: BASE_PATH, chain: nil, nonce_value: nonce)
276
+ raise ArgumentError, "action must be activate or withdraw, not #{action.inspect}" unless %w[activate withdraw].include?(action)
277
+
278
+ sha = nil
279
+ if action == 'activate'
280
+ # One read: the hash ruled on is the hash of the bytes shown.
281
+ raw = File.binread(path)
282
+ sha = Digest::SHA256.hexdigest(raw)
283
+ table, error = parse_table(raw)
284
+ if error
285
+ tty_out.puts "Refused: #{path} is not a valid table (#{error})."
286
+ return { 'recorded' => false, 'reason' => 'invalid', 'detail' => error }
287
+ end
288
+ tty_out.puts "Table: #{TABLE_ID}"
289
+ tty_out.puts "File: #{path}"
290
+ tty_out.puts "sha256: #{sha}"
291
+ tty_out.puts 'Tools the act route may run once this ruling is recorded:'
292
+ table['tools'].sort.each { |name, row| tty_out.puts " #{name.ljust(18)} #{row['effect']}" }
293
+ tty_out.puts "Work-tree writes (reversible_local) only inside: #{table['write_roots'].join(', ')}"
294
+ tty_out.puts " and only for files named #{table['write_name_prefix']}* ending in: #{table['write_extensions'].join(', ')}"
295
+ tty_out.puts ' (with the guard on, work-tree writes are set aside regardless)'
296
+ else
297
+ tty_out.puts "Withdraw table #{TABLE_ID}: the act route will run nothing until a new ruling."
298
+ end
299
+ tty_out.puts
300
+ tty_out.print "Type #{nonce_value} to record this ruling (anything else cancels): "
301
+ tty_out.flush
302
+ typed = tty_in.gets.to_s.strip
303
+ unless typed == nonce_value
304
+ tty_out.puts 'Cancelled. Nothing was recorded.'
305
+ return { 'recorded' => false, 'reason' => 'nonce_mismatch' }
306
+ end
307
+
308
+ record = { 'kind' => RULING_KIND, 'table' => TABLE_ID, 'action' => action, 'sha256' => sha,
309
+ 'attested' => true, 'attestation' => 'terminal_nonce',
310
+ 'ruled_at' => Time.now.utc.iso8601 }
311
+ chain ||= begin
312
+ require 'kairos_mcp/kairos_chain/chain'
313
+ ::KairosMcp::KairosChain::Chain.new
314
+ end
315
+ block = chain.add_block([JSON.generate(record)])
316
+ tty_out.puts "Recorded as block ##{block.index}."
317
+ { 'recorded' => true, 'block_index' => block.index, 'record' => record }
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,54 @@
1
+ # Act-route classification — the agent's allow-list (design v0.3, INV-A1).
2
+ #
3
+ # A step runs on the act route only if its tool is listed here. Every other
4
+ # step is set aside for the operator at the end of the cycle.
5
+ #
6
+ # This is the base table shipped with the gem. It lives beside the code, not
7
+ # under config/, because upgrade never overwrites an operator's config/ file:
8
+ # a shipped change here must reach the instance. It is in force only while the
9
+ # latest attested operator ruling names the sha256 of its exact bytes, so an
10
+ # upgrade that changes it leaves the act route empty until the operator rules
11
+ # again, at a terminal:
12
+ #
13
+ # ruby .kairos/skillsets/agent/bin/agent_rule.rb activate
14
+ #
15
+ # effect: read_only | session_record | llm_sandboxed | reversible_local
16
+ # reversible_local is a work-tree write. It runs only when every location
17
+ # the step names lands inside write_roots with one of write_extensions;
18
+ # any other write is set aside. Under the guard it is always set aside,
19
+ # because the confined route is not wired.
20
+ # locations: the arguments that name the file a step reads or writes (the
21
+ # complexity signals read them; the protected-path check matches every
22
+ # location-like argument by name and does not depend on this list).
23
+ #
24
+ # write_roots / write_extensions / write_name_prefix: where work-tree writes
25
+ # may land, relative to the project. Writes are allowed by place rather than
26
+ # refused by place: naming every location a tool executes (git hooks, editor
27
+ # add-ons, CI) did not close. Inside the place, a file must be named with the
28
+ # prefix, carry one of the extensions, and sit under no hidden ('.') name,
29
+ # because other agent tools load instruction files such as CLAUDE.local.md or
30
+ # GEMINI.md from subdirectories, and none loads draft_*.
31
+ write_roots:
32
+ - docs/drafts
33
+ write_extensions: [.md, .txt]
34
+ write_name_prefix: draft_
35
+
36
+ tools:
37
+ resource_read: { effect: read_only }
38
+ resource_list: { effect: read_only }
39
+ knowledge_get: { effect: read_only }
40
+ knowledge_list: { effect: read_only }
41
+ skills_list: { effect: read_only }
42
+ skills_get: { effect: read_only }
43
+ document_status: { effect: read_only }
44
+ chain_status: { effect: read_only }
45
+ chain_verify: { effect: read_only }
46
+ chain_history: { effect: read_only }
47
+ safe_file_read: { effect: read_only, locations: [path] }
48
+ safe_file_list: { effect: read_only, locations: [path] }
49
+ context_save: { effect: session_record }
50
+ operator_report: { effect: session_record }
51
+ llm_call: { effect: llm_sandboxed }
52
+ safe_file_write: { effect: reversible_local, locations: [path] }
53
+ safe_file_edit: { effect: reversible_local, locations: [path] }
54
+ safe_file_copy: { effect: reversible_local, locations: [source, destination] }