kairos-chain 3.88.2 → 3.89.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 +4 -4
- data/CHANGELOG.md +47 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/bin/agent_rule.rb +69 -0
- data/templates/skillsets/agent/lib/agent/act_classification.rb +322 -0
- data/templates/skillsets/agent/lib/agent/act_classification.yml +54 -0
- data/templates/skillsets/agent/lib/agent/admission.rb +95 -4
- data/templates/skillsets/agent/lib/agent/mandate_adapter.rb +21 -21
- data/templates/skillsets/agent/lib/agent.rb +1 -0
- data/templates/skillsets/agent/plugin/SKILL.md +34 -11
- data/templates/skillsets/agent/test/test_agent_allowlist.rb +724 -0
- data/templates/skillsets/agent/test/test_agent_guard_verdict_record.rb +31 -36
- data/templates/skillsets/agent/test/test_agent_halt_semantics.rb +8 -0
- data/templates/skillsets/agent/test/test_agent_human_mark_gate.rb +24 -16
- data/templates/skillsets/agent/test/test_agent_m3.rb +10 -1
- data/templates/skillsets/agent/tools/agent_step.rb +207 -79
- data/templates/skillsets/autonomos/lib/autonomos/mandate.rb +10 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 109c03e66d55828499533b9d00945390f97cfd8aa56376aa898c099a30218316
|
|
4
|
+
data.tar.gz: 3013372fe35ae2bf4e3e5a4267fed64197e7480da32f2030664eb61766724f44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 824d9d93d7e2c22a991e116a2f8c66acf8d54ecb2469a541c2f51a0ae982b0018d2650f88931daa5cf6da2091bba5ed470e13334c7b341d09fdfa2a7f48c01f8
|
|
7
|
+
data.tar.gz: bcc18d3c8123b54db6b934fc475f8517a7cd3dc2562a34d213c37140f08b45218d461c3685216f6577e9683de111f7ec70a07f9da27e5bfe947d0e740caffc5f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ 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.89.0] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Security — agent: the act route runs from an allow-list
|
|
10
|
+
|
|
11
|
+
The 3.88.x act route refused tools by name, and every review round found
|
|
12
|
+
another tool, route or path nobody had named. **The act route now runs a step
|
|
13
|
+
only if the act-route table classifies its tool.** Every other step is set
|
|
14
|
+
aside — with the steps that depend on it — and handed to the operator as
|
|
15
|
+
`awaiting_operator` at the end of the cycle, in manual and autonomous mode.
|
|
16
|
+
|
|
17
|
+
**After upgrading, the act route runs nothing until you rule the table into
|
|
18
|
+
force, at a terminal:**
|
|
19
|
+
|
|
20
|
+
ruby .kairos/skillsets/agent/bin/agent_rule.rb status
|
|
21
|
+
ruby .kairos/skillsets/agent/bin/agent_rule.rb activate
|
|
22
|
+
|
|
23
|
+
`activate` shows the tools and asks you to type back a nonce; it refuses to run
|
|
24
|
+
without a terminal. The ruling goes on the chain. The table
|
|
25
|
+
(`agent/lib/agent/act_classification.yml`) is in force only while the latest
|
|
26
|
+
ruling names the SHA-256 of its exact bytes, so an upgrade that changes it, a
|
|
27
|
+
`withdraw`, or an edit empties the act route again until you rule. Every agent
|
|
28
|
+
response says under `classification` whether the table is in force.
|
|
29
|
+
|
|
30
|
+
- **Base table**: read-only tools, `context_save`, `operator_report`,
|
|
31
|
+
sandboxed `llm_call`, and `safe_file_write` / `safe_file_edit` /
|
|
32
|
+
`safe_file_copy`.
|
|
33
|
+
- **Work-tree writes are allowed by place**: a plain relative path under
|
|
34
|
+
`docs/drafts/`, a file named `draft_*` ending `.md` or `.txt`, no hidden
|
|
35
|
+
segment and no `workspace_root` argument — checked as written and as
|
|
36
|
+
resolved. Any other write is set aside. With the guard on, every work-tree
|
|
37
|
+
write is set aside.
|
|
38
|
+
- **No step may name a hidden (dot) path, a leading `~`, a control character,
|
|
39
|
+
`keys/` or a private-key file** (`.pem`, `.key`, `.p12`, `.pfx`, `id_rsa`, …),
|
|
40
|
+
reads included; `.git`, `.github`, `.vscode`, `.cursor`, `.gemini` and
|
|
41
|
+
`.envrc` join the protected locations.
|
|
42
|
+
- **The file route (`agent_execute`) is no longer taken.** Its tools cannot be
|
|
43
|
+
classified.
|
|
44
|
+
- **Risk**: the risk gate and the act read the same classified plan. A step's
|
|
45
|
+
risk is the table's, raised (never lowered) by the plan's own label or
|
|
46
|
+
`risk_default`; an unknown label counts as high.
|
|
47
|
+
- DECIDE is told which tools run and where writes may land.
|
|
48
|
+
|
|
49
|
+
Still open: secret files with visible names (e.g. `credentials.json`) are
|
|
50
|
+
readable by `safe_file_read`; a read-place allow-list needs per-instance table
|
|
51
|
+
extensions and comes later. Do not run the agent unattended until approval
|
|
52
|
+
delegation lands.
|
|
53
|
+
|
|
7
54
|
## [3.88.2] - 2026-10-06
|
|
8
55
|
|
|
9
56
|
### Security — agent: the act and its LLM bodies stay inside their gates
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# The operator's terminal ruling on the agent's act-route allow-list
|
|
5
|
+
# (design v0.3, INV-A2 / INV-D8).
|
|
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
|
+
#
|
|
11
|
+
# Options: --data-dir DIR the instance data dir whose chain is written
|
|
12
|
+
# (default: the .kairos this script is installed in)
|
|
13
|
+
#
|
|
14
|
+
# activate and withdraw read a nonce typed back at /dev/tty and refuse to run
|
|
15
|
+
# without a terminal. That keeps an MCP call from producing a ruling through
|
|
16
|
+
# this path. It does not prove a person typed: any process that can start
|
|
17
|
+
# programs can open a pseudo-terminal, read the nonce and type it back (shown
|
|
18
|
+
# in review, 2026-10-06), and such a process can write the chain directly
|
|
19
|
+
# anyway. 'attested' therefore records the path the ruling came by, not who
|
|
20
|
+
# was at the keyboard (INV-D8 boundary; the constrained party is the agent).
|
|
21
|
+
|
|
22
|
+
require 'json'
|
|
23
|
+
|
|
24
|
+
action = ARGV.find { |a| %w[activate withdraw status].include?(a) }
|
|
25
|
+
abort 'usage: agent_rule.rb activate|withdraw|status [--data-dir DIR]' unless action
|
|
26
|
+
|
|
27
|
+
idx = ARGV.index('--data-dir')
|
|
28
|
+
data_dir = idx ? File.expand_path(ARGV[idx + 1].to_s) : File.expand_path('../../..', __dir__)
|
|
29
|
+
abort "no data dir at #{data_dir}" unless File.directory?(data_dir)
|
|
30
|
+
|
|
31
|
+
$LOAD_PATH.unshift(ENV['KAIROS_SERVER_LIB']) if ENV['KAIROS_SERVER_LIB']
|
|
32
|
+
begin
|
|
33
|
+
require 'kairos_mcp'
|
|
34
|
+
require 'kairos_mcp/kairos_chain/chain'
|
|
35
|
+
rescue LoadError => e
|
|
36
|
+
abort "cannot load kairos_mcp (#{e.message}); install the kairos-chain gem or set KAIROS_SERVER_LIB"
|
|
37
|
+
end
|
|
38
|
+
KairosMcp.data_dir = data_dir
|
|
39
|
+
|
|
40
|
+
lib = File.expand_path('../lib', __dir__)
|
|
41
|
+
require File.join(lib, 'agent', 'admission')
|
|
42
|
+
require File.join(lib, 'agent', 'mandate_adapter')
|
|
43
|
+
require File.join(lib, 'agent', 'act_classification')
|
|
44
|
+
ac = KairosMcp::SkillSets::Agent::ActClassification
|
|
45
|
+
path = ac::BASE_PATH
|
|
46
|
+
|
|
47
|
+
puts "Data dir: #{data_dir}"
|
|
48
|
+
if action == 'status'
|
|
49
|
+
eff = ac.load_effective(path: path)
|
|
50
|
+
puts "Table #{ac::TABLE_ID}: #{eff['status']}"
|
|
51
|
+
puts " file sha256: #{eff['sha256']}"
|
|
52
|
+
puts " ruled sha256: #{eff['ruled_sha256'] || '(none)'}"
|
|
53
|
+
puts " detail: #{eff['detail']}" if eff['detail']
|
|
54
|
+
puts " tools in force: #{eff['tools'].keys.sort.join(', ')}" unless eff['tools'].empty?
|
|
55
|
+
exit 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
tty = begin
|
|
59
|
+
File.open('/dev/tty', 'r+')
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
abort "#{action} needs a terminal (/dev/tty: #{e.class}). Run it yourself in a terminal."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
result = ac.interactive_rule(action: action, tty_in: tty, tty_out: tty, path: path)
|
|
66
|
+
ensure
|
|
67
|
+
tty.close
|
|
68
|
+
end
|
|
69
|
+
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] }
|
|
@@ -62,8 +62,23 @@ module KairosMcp
|
|
|
62
62
|
# .codex; .mcp.json) and the instruction files every agent body reads
|
|
63
63
|
# (CLAUDE.md, AGENTS.md). Checked by destination, not by tool name, so
|
|
64
64
|
# a tool that was not anticipated is covered by its arguments.
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
#
|
|
66
|
+
# Also protected: what git and the operator's development tools execute
|
|
67
|
+
# or load as configuration (design v0.3 §6.5). A hook written into
|
|
68
|
+
# .git/hooks, or core.hooksPath set in .git/config, runs as the operator
|
|
69
|
+
# on the next git command, including a plain `git status` — which would
|
|
70
|
+
# hand the act the operator's shell. The same holds for CI workflows
|
|
71
|
+
# (.github), editor tasks (.vscode), other agents' rules (.cursor,
|
|
72
|
+
# .gemini, .cursorrules) and direnv (.envrc).
|
|
73
|
+
#
|
|
74
|
+
# And private keys: a keys/ directory anywhere, the usual private-key
|
|
75
|
+
# file names and extensions. The base table classifies safe_file_read,
|
|
76
|
+
# and a key read in one step could leave through an API llm_call in the
|
|
77
|
+
# next (keys/mmp_keypair.pem sits at this project's root).
|
|
78
|
+
PROTECTED_SEGMENTS = %w[.kairos .claude .codex .git .github .vscode .cursor .gemini keys].freeze
|
|
79
|
+
PROTECTED_BASENAMES = %w[claude.md agents.md .mcp.json .envrc .cursorrules .cursorignore
|
|
80
|
+
id_rsa id_dsa id_ecdsa id_ed25519].freeze
|
|
81
|
+
PROTECTED_EXTENSIONS = %w[.pem .key .p12 .pfx].freeze
|
|
67
82
|
|
|
68
83
|
# Argument names that carry a filesystem location, matched by word
|
|
69
84
|
# (split on '_' / '-') so 'profile' or 'output_format' is not a path.
|
|
@@ -146,7 +161,11 @@ module KairosMcp
|
|
|
146
161
|
next [{ 'step_id' => step['step_id'], 'tool_name' => step['tool_name'], 'path' => '(no root)',
|
|
147
162
|
'rule' => 'unresolvable' }] if candidate_roots.empty? && !pairs.empty?
|
|
148
163
|
pairs.filter_map do |_key, raw|
|
|
149
|
-
|
|
164
|
+
# A leading '~' is read as the home directory by File.expand_path
|
|
165
|
+
# but kept as a literal directory name by the tools (File.join), so
|
|
166
|
+
# the check and the write would disagree about where it lands.
|
|
167
|
+
rule = if raw.start_with?('~') then 'tilde'
|
|
168
|
+
elsif protected_name?(raw) then 'protected'
|
|
150
169
|
elsif raw.split(%r{[/\\]}).include?('..') then 'dotdot'
|
|
151
170
|
else candidate_roots.lazy.map { |root| touch_rule(raw, root, protected, allowed) }.find(&:itself)
|
|
152
171
|
end
|
|
@@ -199,11 +218,83 @@ module KairosMcp
|
|
|
199
218
|
end
|
|
200
219
|
end
|
|
201
220
|
|
|
221
|
+
# True when a work-tree write is inside its allowed place (design v0.3
|
|
222
|
+
# §6.5 as ruled 2026-10-06). The accepted input is deliberately narrow,
|
|
223
|
+
# so that this check and the tool read the path the same way: every
|
|
224
|
+
# location argument must be a plain relative path, written as
|
|
225
|
+
# '<write root>/.../<name>', with no root argument beside it. Then:
|
|
226
|
+
# - no segment below the root may start with '.' (no hidden files or
|
|
227
|
+
# directories, where agent tools keep rules), as written and as
|
|
228
|
+
# resolved;
|
|
229
|
+
# - the file name must start with name_prefix and carry one of
|
|
230
|
+
# extensions, as written and as resolved (no agent tool loads
|
|
231
|
+
# 'draft_*' as instructions; a link named .md is not a document);
|
|
232
|
+
# - resolved with symlinks followed against every default root a tool
|
|
233
|
+
# may use, the path must land strictly inside the same write root.
|
|
234
|
+
# A leading '/', '~' or '\', a backslash, an empty, '.' or '..' segment,
|
|
235
|
+
# a NUL or other control character, a root argument, or anything that
|
|
236
|
+
# cannot be resolved makes the step not inside. Never raises.
|
|
237
|
+
def within_write_scope?(args, write_roots, extensions, roots, project_root:, name_prefix: nil)
|
|
238
|
+
return false unless args.is_a?(Hash)
|
|
239
|
+
|
|
240
|
+
pairs = path_pairs(args)
|
|
241
|
+
root_args, files = pairs.partition { |k, _| (key_tokens(k) & ROOT_TOKENS).any? }
|
|
242
|
+
return false if files.empty? || !root_args.empty?
|
|
243
|
+
|
|
244
|
+
exts = Array(extensions).map(&:downcase)
|
|
245
|
+
candidates = Array(roots).compact.map(&:to_s).reject(&:empty?)
|
|
246
|
+
rels = Array(write_roots).map(&:to_s)
|
|
247
|
+
return false if rels.empty? || exts.empty? || candidates.empty?
|
|
248
|
+
|
|
249
|
+
name_ok = lambda do |name|
|
|
250
|
+
exts.include?(File.extname(name).downcase) && (name_prefix.nil? || name.start_with?(name_prefix))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
files.all? do |_key, raw|
|
|
254
|
+
next false if raw.match?(/[[:cntrl:]\\]/) || raw.start_with?('/', '~')
|
|
255
|
+
|
|
256
|
+
segments = raw.split('/', -1)
|
|
257
|
+
next false if segments.any? { |p| p.empty? || p == '.' || p == '..' }
|
|
258
|
+
|
|
259
|
+
rel = rels.find { |r| raw.start_with?("#{r}/") }
|
|
260
|
+
next false unless rel
|
|
261
|
+
|
|
262
|
+
below = raw.delete_prefix("#{rel}/").split('/')
|
|
263
|
+
next false if below.empty? || below.any? { |p| p.start_with?('.') } || !name_ok.call(below.last)
|
|
264
|
+
|
|
265
|
+
dir = canonical(File.expand_path(rel, project_root))
|
|
266
|
+
candidates.all? do |root|
|
|
267
|
+
full = canonical(File.expand_path(raw, root))
|
|
268
|
+
next false unless full.start_with?("#{dir}/")
|
|
269
|
+
|
|
270
|
+
resolved = full.delete_prefix("#{dir}/").split('/')
|
|
271
|
+
!resolved.empty? && resolved.none? { |p| p.start_with?('.') } && name_ok.call(resolved.last)
|
|
272
|
+
rescue StandardError
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
rescue StandardError
|
|
276
|
+
false
|
|
277
|
+
end
|
|
278
|
+
rescue StandardError
|
|
279
|
+
false
|
|
280
|
+
end
|
|
281
|
+
|
|
202
282
|
# A protected segment or file name anywhere in the path refuses it,
|
|
203
283
|
# whatever root the tool resolves against.
|
|
204
284
|
def protected_name?(raw)
|
|
285
|
+
# A control character (NUL included) is refused outright: no real
|
|
286
|
+
# location needs one, and File.extname raises on NUL.
|
|
287
|
+
return true if raw.to_s.match?(/[[:cntrl:]]/)
|
|
288
|
+
|
|
205
289
|
parts = raw.split(%r{[/\\]}).map(&:downcase)
|
|
206
|
-
|
|
290
|
+
# No hidden name, anywhere, for any step (operator ruling 2026-10-06):
|
|
291
|
+
# dotfiles and dot-directories are where secrets (.env, .ssh, .aws,
|
|
292
|
+
# .netrc) and tool configuration live, and naming them one by one did
|
|
293
|
+
# not close. '.' and '..' are not names; '..' is refused on its own.
|
|
294
|
+
return true if parts.any? { |p| p.start_with?('.') && p != '.' && p != '..' }
|
|
295
|
+
|
|
296
|
+
parts.any? { |p| PROTECTED_SEGMENTS.include?(p) } || PROTECTED_BASENAMES.include?(parts.last) ||
|
|
297
|
+
PROTECTED_EXTENSIONS.include?(File.extname(parts.last.to_s))
|
|
207
298
|
end
|
|
208
299
|
|
|
209
300
|
# True when the path, resolved against root, lands in a protected
|