kairos-chain 3.46.0 → 3.49.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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -0
  3. data/lib/kairos_mcp/skillset.rb +10 -0
  4. data/lib/kairos_mcp/tool_registry.rb +42 -9
  5. data/lib/kairos_mcp/version.rb +1 -1
  6. data/templates/skillsets/confidentiality_guard/config/confidentiality_guard.yml +18 -0
  7. data/templates/skillsets/confidentiality_guard/config/profile.example.yml +34 -0
  8. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/canon.rb +52 -0
  9. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/policy.rb +144 -0
  10. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/recorder.rb +114 -0
  11. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/regime.rb +318 -0
  12. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/surfaces.rb +110 -0
  13. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/verdict.rb +86 -0
  14. data/templates/skillsets/confidentiality_guard/skillset.json +20 -0
  15. data/templates/skillsets/confidentiality_guard/test/test_cg_guard.rb +566 -0
  16. data/templates/skillsets/confidentiality_guard/tools/cg_status.rb +51 -0
  17. data/templates/skillsets/synoptis/bin/sda_verify.rb +260 -0
  18. data/templates/skillsets/synoptis/bin/sdp_verify.rb +174 -0
  19. data/templates/skillsets/synoptis/lib/synoptis/anchoring/aggregate_disclosure.rb +221 -0
  20. data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/sdp-1.md +182 -0
  21. data/templates/skillsets/synoptis/lib/synoptis/anchoring/ec_group.rb +196 -0
  22. data/templates/skillsets/synoptis/lib/synoptis/anchoring/pedersen.rb +133 -0
  23. data/templates/skillsets/synoptis/lib/synoptis/anchoring/range_proof.rb +266 -0
  24. data/templates/skillsets/synoptis/lib/synoptis/anchoring/selective_disclosure.rb +540 -0
  25. data/templates/skillsets/synoptis/test/test_sda_aggregate.rb +214 -0
  26. data/templates/skillsets/synoptis/test/test_sda_range_proof.rb +238 -0
  27. data/templates/skillsets/synoptis/test/test_sdp_disclosure.rb +434 -0
  28. metadata +23 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 372dd921a298d45c97b957ae724a253bb22da73b5bb211025fe894eab5de4ff7
4
- data.tar.gz: f15e37a2caeef2d4d08b92f49608aa52307b1a93124a1742d3f40c10ae145bf8
3
+ metadata.gz: 69d783a71fb5c56c3b7bfc549695f337ad9099d2db5beaf0ae557b845f854a2f
4
+ data.tar.gz: 3f858ee398aa3ecbe24c664e5618653ab1bfd38f6a5a01c6779270cefc4d7e68
5
5
  SHA512:
6
- metadata.gz: c9fc60d9acee8dcb8b7a2bfb43021d3c8b129cb94471fe8488092683ade4755665faa72860fb8c1bed77d254e977226c3d8cd85bec4dfe95eea2d0dc12352190
7
- data.tar.gz: e5769c4b23ce1daef9704423f41e097d8969f5b86442ab1592beab4e3dc7623a3a79e30aba51d7ca2d5621e55fd635db740b5489761995bc2b9b33de8eb0b80e
6
+ metadata.gz: 16e67612af99d2dc86f85d9d128a3e5906f07f72677f7a8ff547685b9106e1c62b3dea2161ce776fcd68819676f7b665b8b8a9e127a2e69c6cc33831685aab60
7
+ data.tar.gz: b202b151145f3bb7c1f1383450129d8b3455f0eeb3d9fa7716b3dea173d9c3eb06a56d3fa90b3236b0e8585cb88a3e2276707512e8cd06c833f64efd6ad7915a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,118 @@ 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.49.0] - 2026-07-22
8
+
9
+ ### ZK aggregate reproducibility spike (AUD-L4, Phases 1+2) — KairosChain's first genuine zero-knowledge proof
10
+
11
+ Additive demonstrator in the `synoptis` SkillSet (candidate convention id
12
+ `sda-1` if later promoted). Proves, over a public DOI set, that an auditor's
13
+ published aggregate (mean reproducibility) is honestly computed from
14
+ per-item scores that stay secret — with each hidden score proven in-range
15
+ via a genuine zero-knowledge proof. Pure-Ruby secp256k1; no trusted setup;
16
+ no new runtime dependency (OpenSSL is a test-only oracle).
17
+
18
+ - Phase 1 — `ec_group.rb` (hand-rolled prime-order secp256k1, NUMS second
19
+ generator H), `pedersen.rb` (perfectly-hiding commitments, additive
20
+ homomorphism, aggregate opening + Σr-hiding Schnorr proof),
21
+ `aggregate_disclosure.rb` (SDP-2 dual-commitment score binding to rpr-1
22
+ endorsements, DOI-set commitment, mean verification).
23
+ - Phase 2 — `range_proof.rb` (`Synoptis::Anchoring::RangeProof`):
24
+ bit-decomposition range proof for s ∈ [0, 7] (3-bit band). Per-bit
25
+ Cramer-Damgård-Schoenmakers '94 Schnorr OR proofs (b ∈ {0,1}) plus the
26
+ reconstruction invariant Σ 2^j·B_j = C; Fiat-Shamir (SHA-256) with
27
+ metadata- and index-bound transcripts. Fail-closed verifier admission
28
+ (closed schema, type-strict vmax/bits, point/scalar canonicality,
29
+ identity rejection) closes range-escape and encoding-malleability.
30
+ - CLI: `sda_verify.rb` gains `commit`, `aggregate-verify`,
31
+ `aggregate-schnorr`, `binding`, `doi-set`, `range-verify`,
32
+ `full-audit-verify` (C1 coverage + C3 aggregate + C4 range in one pass).
33
+ - Tests: 93 new (52 Phase 1 + 41 Phase 2) incl. the mandatory forgery
34
+ negatives (an out-of-range 32000 score cannot yield an accepting proof);
35
+ scalar-mul cross-validated against OpenSSL. Existing suites unchanged
36
+ (507 total green).
37
+ - Design memos under `docs/drafts/`; design and implementation each
38
+ converged through philosophy-briefed multi-LLM review (impl round 2:
39
+ 6/6 APPROVE).
40
+
41
+ ## [3.48.0] - 2026-07-22
42
+
43
+ ### Confidentiality Guard SkillSet — slice 1 (CG-1..CG-6)
44
+
45
+ New `confidentiality_guard` SkillSet implementing design v0.3 (FROZEN,
46
+ review-converged). A fail-closed, selectable-off data-confidentiality regime
47
+ for enterprise environments. Slice 1 covers inward (L2/L1 write) and
48
+ restricted-storage-read surfaces; outward crossings and unmapped
49
+ resource-scheme reads are denied wholesale under the CG-1 coverage clause.
50
+
51
+ - `lib/confidentiality_guard/regime.rb`: regime lifecycle + tool-surface
52
+ interception via a `ToolRegistry` gate (verdict precedes effect, CG-2).
53
+ Activation is environment-level (`KAIROS_CONFIDENTIALITY_GUARD` env >
54
+ config), read at load time via the skillset `activation_hook` (independent
55
+ of tool instantiation) with an eager `cg_status` belt-and-suspenders call;
56
+ policy pinned at activation; activation AND cessation recorded; gate
57
+ registered only after the regime is active (no fail-open window).
58
+ - `policy.rb`: pinned, content-addressed policy profile; `File.realpath`-
59
+ matching path resolution (symlink- and `..`-safe, parity with the
60
+ external-tools readers). `surfaces.rb`: crossing classification with a
61
+ release-gated enrollment manifest. `verdict.rb`: deterministic LLM-free
62
+ conjunctive verdict (designation closed-world + content detection),
63
+ versioned verdict basis. `recorder.rb`: constitutive chain records bound
64
+ by salted commitments (sdp-1-aligned), never containing content; read
65
+ records carry the designation id, not the raw path. `canon.rb`: shared
66
+ canonical serialization faithful to false/nil and mixed string/symbol keys.
67
+ - `tools/cg_status.rb`: read-only regime-state tool (CG-1).
68
+ - 67 design-constraint + regression tests (`test/test_cg_guard.rb`).
69
+
70
+ Core (minimal, guard-enabling): `tool_registry.rb` adds `FailClosedError`
71
+ (re-raised by the loader, never swallowed), per-skillset isolated loading,
72
+ and fail-closed `activation_hook` invocation; `skillset.rb` adds the
73
+ `activation_hook` accessor. Impl review: 3 rounds with executable-probe
74
+ personas; every deployment-grounded finding (enabled-but-unguarded start,
75
+ copy source-read bypass, path-resolution parity, symlink+`..` bypass, loader
76
+ fail-closed) resolved.
77
+
78
+ ## [3.47.0] - 2026-07-22
79
+
80
+ ### Synoptis — selective disclosure sdp-1 (AUD-L4 slice 1)
81
+
82
+ Implements AUD-L4 design v0.3 (SDP-1..5, FROZEN, review-converged) slice 1
83
+ in the synoptis SkillSet. All additive: sdp-1 builds on khab-1/map-1/rpr-1
84
+ exactly as each prior slice built on its predecessors; no pre-existing file
85
+ changed.
86
+
87
+ - `conventions/sdp-1.md`: content-addressed selective-disclosure convention —
88
+ hash-based salted field-level disclosure (content-blinding: field values
89
+ hidden, record digest / field names / count public; the ZK membership
90
+ family is disclosed as out of scope for a later convention). Field
91
+ commitments (one salted digest per field, total coverage, `sdp-1/field`
92
+ domain separation), convention-determined disclosure profile (closed
93
+ schema; predicate + opened set + currency, `format` always opened),
94
+ presentation (closed schema per shape), three predicates
95
+ (typed-existence / claimed-verdict / conforming-verdict with foreignness,
96
+ anterior-tolerance assessment, and sibling-aware tolerance-target
97
+ coherence inside the checked predicate), extent-bounded currency scan
98
+ (issuer rule, residues disclosed unconditionally), computational
99
+ hiding/soundness base stated (no trusted setup).
100
+ - `selective_disclosure.rb`: build/parse/verify for field commitments,
101
+ profile, presentation (refuse-not-coerce, canonical-serialization
102
+ equality everywhere); signature verification from the record DIGEST alone
103
+ via the unchanged map-1 §1.1 attestation signature — blinded verdicts
104
+ without record content; conforming-verdict refuses (never degrades) when
105
+ operator credential or rpr-1 §2.1 assessment material is missing;
106
+ currency scan with unestablished-vs-unretracted discipline.
107
+ - `bin/sdp_verify.rb`: offline verifier (commit / binding / presentation /
108
+ profile / currency / convention), exit-code discipline (0/1/2), strict
109
+ intake (delete_suffix, canonical numerals).
110
+ - `test/test_sdp_disclosure.rb`: 61 design-constraint assertions
111
+ (SDP-1..5), including crafted-record shape refusal, verdict-vocabulary
112
+ closure, sibling-target coherence (rpr-1 §2.1 pooling agreement),
113
+ hiding of withheld values across artifacts and error paths, and
114
+ khab/map/rpr non-interference (37/75/76/88 regression green).
115
+
116
+ Implementation review: 3 rounds, R3 4/6 APPROVE + executable-probe
117
+ personas 2/2, zero remaining P0/P1 (dispositions in docs/drafts/).
118
+
7
119
  ## [3.46.0] - 2026-07-22
8
120
 
9
121
  ### Synoptis — reproduction endorsement rpr-1 (AUD-L3 slice 1)
@@ -87,6 +87,16 @@ module KairosMcp
87
87
  @metadata['config_files'] || []
88
88
  end
89
89
 
90
+ # Optional fail-closed activation hook: a fully-qualified class name
91
+ # whose `activate_on_load!` runs at skillset-load time, independent of
92
+ # whether any of the SkillSet's tools are instantiated. Used by
93
+ # fail-closed regimes (confidentiality_guard) so an enabled guard
94
+ # registers its enforcement even if no tool is ever constructed.
95
+ def activation_hook
96
+ name = @metadata['activation_hook']
97
+ name.is_a?(String) && !name.empty? ? name : nil
98
+ end
99
+
90
100
  def knowledge_dir_names
91
101
  @metadata['knowledge_dirs'] || []
92
102
  end
@@ -5,6 +5,12 @@ require_relative 'lifecycle_hook'
5
5
 
6
6
  module KairosMcp
7
7
  class ToolRegistry
8
+ # Raised by SkillSets whose activation must never be swallowed
9
+ # (fail-closed regimes, e.g. confidentiality_guard): the loader
10
+ # re-raises instead of degrading to a warn, so an enabled regime can
11
+ # never be silently skipped and leave the server unguarded.
12
+ class FailClosedError < StandardError; end
13
+
8
14
  # Authorization denial raised by registered gates
9
15
  class GateDeniedError < StandardError
10
16
  attr_reader :tool_name, :role
@@ -273,17 +279,44 @@ module KairosMcp
273
279
 
274
280
  manager = SkillSetManager.new
275
281
  manager.enabled_skillsets.each do |skillset|
276
- skillset.load!
277
- skillset.tool_class_names.each do |cls|
278
- # Phase 1.5: thread source attribution for capability_status manifest
279
- register_if_defined(cls, source: "skillset:#{skillset.name}")
280
- end
281
- # 24/7 v0.4 §2.3 register lifecycle hooks declared by this SkillSet.
282
- skillset.lifecycle_hooks.each do |hook_name, class_name|
283
- register_lifecycle_hook(hook_name, class_name, skillset_name: skillset.name)
282
+ # Per-skillset isolation: one failing SkillSet must not abort the
283
+ # loop and silently skip later ones (a fail-closed regime loaded
284
+ # later would otherwise never register its gate).
285
+ begin
286
+ skillset.load!
287
+ # Fail-closed activation hook (before tool registration): a
288
+ # regime that ships enabled must register its enforcement at load
289
+ # time, not as a side effect of some tool being instantiated.
290
+ # ANY failure to resolve or run the hook is fail-closed: a
291
+ # missing/typo'd hook constant must not degrade to a warn and
292
+ # leave an enabled regime unguarded.
293
+ if (hook = skillset.activation_hook)
294
+ begin
295
+ LifecycleHook.validate_class_name!(hook)
296
+ Object.const_get(hook).activate_on_load!(registry_class: self.class)
297
+ rescue FailClosedError
298
+ raise
299
+ rescue StandardError => e
300
+ raise FailClosedError,
301
+ "activation_hook '#{hook}' (skillset '#{skillset.name}') failed to activate: " \
302
+ "#{e.class}: #{e.message}"
303
+ end
304
+ end
305
+ skillset.tool_class_names.each do |cls|
306
+ # Phase 1.5: thread source attribution for capability_status manifest
307
+ register_if_defined(cls, source: "skillset:#{skillset.name}")
308
+ end
309
+ # 24/7 v0.4 §2.3 — register lifecycle hooks declared by this SkillSet.
310
+ skillset.lifecycle_hooks.each do |hook_name, class_name|
311
+ register_lifecycle_hook(hook_name, class_name, skillset_name: skillset.name)
312
+ end
313
+ rescue LifecycleHook::Conflict, FailClosedError
314
+ raise # never swallow — integrity/fail-closed regimes depend on detection
315
+ rescue StandardError => e
316
+ warn "[ToolRegistry] Failed to load SkillSet '#{skillset.name}': #{e.message}"
284
317
  end
285
318
  end
286
- rescue LifecycleHook::Conflict
319
+ rescue LifecycleHook::Conflict, FailClosedError
287
320
  raise # never swallow — Bootstrap integrity depends on detection
288
321
  rescue StandardError => e
289
322
  warn "[ToolRegistry] Failed to load SkillSet tools: #{e.message}"
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.46.0"
2
+ VERSION = "3.49.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,18 @@
1
+ # Confidentiality Guard SkillSet configuration (CG-1).
2
+ #
3
+ # The regime ships selectable-off. Flipping `enabled: true` activates the
4
+ # fail-closed confidentiality regime as a whole at the next instance start —
5
+ # there is no per-surface toggle (design v0.3, CG-1).
6
+ #
7
+ # Activation precedence (mechanism choice recorded here, invariant in CG-1):
8
+ # 1. Environment variable KAIROS_CONFIDENTIALITY_GUARD ("1" forces on,
9
+ # "0" forces off) — highest precedence, environment-level act.
10
+ # 2. This file's guard.enabled.
11
+ # Both are read at instance start, outside the deciding model's reach.
12
+ guard:
13
+ enabled: false
14
+ # Path to the enforced policy profile, relative to this config directory.
15
+ # The profile is pinned (content-addressed) at activation; edits to the
16
+ # file are inert until a subsequent activation-level act adopts them
17
+ # (CG-1), and edits made through instance tools are recorded (CG-4).
18
+ profile: profile.yml
@@ -0,0 +1,34 @@
1
+ # Confidentiality Guard policy profile — EXAMPLE (copy to profile.yml and edit).
2
+ #
3
+ # The profile is data, not code (design v0.3 §4). Its designations span the
4
+ # guarded crossing classes; a class left undesignated is wholly denied
5
+ # (CG-1 fail-closed). Activation with no profile file at all is total denial.
6
+ #
7
+ # All patterns must be valid Ruby regexes; an invalid pattern aborts
8
+ # activation (fail-closed: the regime never runs on a policy it cannot pin).
9
+ version: 1
10
+
11
+ # Persistent-layer admissions (inward writes). Absent layer => denied.
12
+ persistent_admissions:
13
+ l2: permitted
14
+ l1: denied
15
+
16
+ # Restricted storage designations. Reads of designated roots are guarded
17
+ # crossings: recorded when permitted (CG-4), denied otherwise. Paths are
18
+ # resolved relative to the process working directory unless absolute.
19
+ restricted_storage:
20
+ - id: chain_store
21
+ path: .kairos/storage
22
+ reads: denied
23
+
24
+ # Content classes. A detection on a designated crossing denies the call
25
+ # (transform-and-pass is a Slice 2 opt-in; any `consequence` other than
26
+ # `deny` is unenforced in Slice 1 and therefore denies — CG-1 coverage).
27
+ # Note: detection runs over the canonical JSON of the call's arguments, so
28
+ # a key/value pair like {api_key: "..."} presents as "api_key":"..." — the
29
+ # pattern therefore tolerates quotes between the name and the separator.
30
+ content_classes:
31
+ - id: api_key
32
+ pattern: '(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?token)\b["'']?\s*[:=]'
33
+ - id: private_key_block
34
+ pattern: '-----BEGIN [A-Z ]*PRIVATE KEY-----'
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module KairosMcp
6
+ module SkillSets
7
+ module ConfidentialityGuard
8
+ # Shared canonical serialization for policy pinning (CG-1/CG-3) and
9
+ # content presentation/commitment (CG-3/CG-4). One encoder for both
10
+ # so the pinned-policy hash and the content commitment can never
11
+ # drift apart.
12
+ #
13
+ # Faithfulness rules (impl review R1):
14
+ # - false and nil are preserved distinctly (no truthiness fallback).
15
+ # - Hash keys are stringified; when a hash carries BOTH string and
16
+ # symbol forms of one name, BOTH entries are emitted (sorted by
17
+ # stringified key, original order as tiebreak) — dropping either
18
+ # would be a detection miss on a security guard. The output is a
19
+ # deterministic function of the presented object.
20
+ module Canon
21
+ module_function
22
+
23
+ def canonical(obj)
24
+ case obj
25
+ when Hash
26
+ entries = obj.map.with_index { |(k, v), i| [k.to_s, i, v] }
27
+ entries.sort_by! { |key, i, _| [key, i] }
28
+ "{#{entries.map { |key, _, v| "#{key.to_json}:#{canonical(v)}" }.join(',')}}"
29
+ when Array
30
+ "[#{obj.map { |v| canonical(v) }.join(',')}]"
31
+ else
32
+ obj.to_json
33
+ end
34
+ end
35
+
36
+ # Deep string-keyed copy for classification (path extraction must
37
+ # not depend on the caller's key form). Later entries win on
38
+ # collision, matching Ruby hash assignment semantics.
39
+ def stringify(obj)
40
+ case obj
41
+ when Hash
42
+ obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
43
+ when Array
44
+ obj.map { |v| stringify(v) }
45
+ else
46
+ obj
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'digest'
6
+ require_relative 'canon'
7
+
8
+ module KairosMcp
9
+ module SkillSets
10
+ module ConfidentialityGuard
11
+ # Pinned policy profile (design v0.3 §4, CG-1/CG-3).
12
+ #
13
+ # The profile is loaded once at activation and pinned by content hash;
14
+ # subsequent edits to the file are invisible to the running regime.
15
+ # A missing profile yields the empty policy: every designation absent,
16
+ # therefore total denial (CG-1 fail-closed, §4 zero-profile case).
17
+ class Policy
18
+ class ActivationError < StandardError; end
19
+
20
+ # Detection-machinery version, part of the versioned verdict basis
21
+ # (CG-3). Bump when the detection semantics below change.
22
+ ENGINE_VERSION = 'cg-1/1'
23
+
24
+ attr_reader :sha256, :profile_path
25
+
26
+ def self.load(profile_path)
27
+ raw = if profile_path && File.file?(profile_path)
28
+ YAML.safe_load(File.read(profile_path)) || {}
29
+ else
30
+ {}
31
+ end
32
+ raise ActivationError, 'profile must be a YAML mapping' unless raw.is_a?(Hash)
33
+ new(raw, profile_path: profile_path)
34
+ rescue Psych::Exception => e
35
+ raise ActivationError, "profile unparseable: #{e.message}"
36
+ end
37
+
38
+ def initialize(data, profile_path: nil)
39
+ @data = data
40
+ @profile_path = profile_path
41
+ @sha256 = Digest::SHA256.hexdigest(Canon.canonical(data))
42
+ @content_classes = compile_content_classes(data['content_classes'])
43
+ @restricted = normalize_restricted(data['restricted_storage'])
44
+ end
45
+
46
+ def empty?
47
+ @data.empty?
48
+ end
49
+
50
+ # 'permitted' / 'denied' / nil (absent designation => denial, CG-1)
51
+ def persistent_admission(layer)
52
+ value = @data.dig('persistent_admissions', layer.to_s)
53
+ value.is_a?(String) ? value : nil
54
+ end
55
+
56
+ # Returns the matching restricted-storage designation for a path,
57
+ # or nil when the path is not designated (an undesignated read is
58
+ # not a guarded crossing — design v0.3 §1 scope (c)).
59
+ def restricted_entry(path)
60
+ return nil unless path.is_a?(String) && !path.empty?
61
+ resolved = Policy.resolve(path)
62
+ @restricted.find { |entry| under_root?(resolved, entry[:root]) }
63
+ end
64
+
65
+ # True when `path` is the root itself or lives beneath it. Uses a
66
+ # separator-normalized prefix so a root of "/" (whose separator
67
+ # tail would be "//") still matches its children.
68
+ def under_root?(path, root)
69
+ return true if path == root
70
+ prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
71
+ path.start_with?(prefix)
72
+ end
73
+
74
+ # Path normalization that matches the read tools' File.realpath
75
+ # semantics (impl review R1/R3). Crucially it does NOT lexically
76
+ # collapse ".." via File.expand_path first — that would discard a
77
+ # symlink component and diverge from what the tool actually reads
78
+ # (e.g. "link/../data" where link -> /root/sub must resolve through
79
+ # the filesystem, not lexically to the parent of link). The full
80
+ # path is realpath'd when it exists; otherwise the deepest existing
81
+ # prefix is realpath'd (walking the raw path, ".." included) and the
82
+ # non-existing tail is appended.
83
+ def self.resolve(path)
84
+ candidate = path.to_s
85
+ candidate = File.join(Dir.pwd, candidate) unless candidate.start_with?(File::SEPARATOR)
86
+ return File.realpath(candidate) if File.exist?(candidate)
87
+
88
+ tail = []
89
+ current = candidate
90
+ loop do
91
+ parent = File.dirname(current)
92
+ tail.unshift(File.basename(current))
93
+ current = parent
94
+ break if current == File.dirname(current) # reached the fs root
95
+ return File.join(File.realpath(current), *tail) if File.exist?(current)
96
+ end
97
+ # Nothing along the path exists: best-effort lexical form.
98
+ File.expand_path(candidate)
99
+ rescue ArgumentError
100
+ # Malformed path (e.g. NUL byte): re-raise so the caller's
101
+ # resolve_target turns it into an unextractable denial rather
102
+ # than a crash.
103
+ raise
104
+ end
105
+
106
+ # First content-class detection hit on the presented content, or nil.
107
+ # Deterministic: classes are evaluated in profile order (CG-3).
108
+ def content_class_hit(content)
109
+ return nil unless content.is_a?(String)
110
+ @content_classes.find { |klass| klass[:regexp].match?(content) }
111
+ end
112
+
113
+ private
114
+
115
+ def compile_content_classes(classes)
116
+ Array(classes).map do |klass|
117
+ raise ActivationError, 'content_classes entries must be mappings' unless klass.is_a?(Hash)
118
+ id = klass['id'].to_s
119
+ pattern = klass['pattern']
120
+ raise ActivationError, "content class #{id.inspect} missing id or pattern" if id.empty? || !pattern.is_a?(String)
121
+ begin
122
+ { id: id, regexp: Regexp.new(pattern) }
123
+ rescue RegexpError => e
124
+ raise ActivationError, "content class #{id.inspect} has invalid pattern: #{e.message}"
125
+ end
126
+ end
127
+ end
128
+
129
+ def normalize_restricted(entries)
130
+ Array(entries).map do |entry|
131
+ raise ActivationError, 'restricted_storage entries must be mappings' unless entry.is_a?(Hash)
132
+ path = entry['path']
133
+ raise ActivationError, 'restricted_storage entry missing path' unless path.is_a?(String) && !path.empty?
134
+ {
135
+ id: (entry['id'] || path).to_s,
136
+ root: Policy.resolve(path),
137
+ reads: entry['reads'].is_a?(String) ? entry['reads'] : nil
138
+ }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'securerandom'
6
+ require_relative 'policy'
7
+
8
+ module KairosMcp
9
+ module SkillSets
10
+ module ConfidentialityGuard
11
+ # Constitutive audit records (design v0.3 CG-4).
12
+ #
13
+ # Records are commitment-bound: they identify the verdict (versioned
14
+ # basis, grounding rule, crossing descriptor) and bind the presented
15
+ # content by a salted commitment, never containing it. The commitment
16
+ # construction follows the sdp-1 salted field-commitment pattern
17
+ # (domain-tagged SHA-256 over salt + canonical content; 16-byte hex
18
+ # salt); the salt stays with the operator-visible report, off-chain
19
+ # (salt custody is a §8 choice — slice 1 keeps it operator-side).
20
+ #
21
+ # The audit write itself is constitutive of the guard's operation,
22
+ # not a guarded crossing (CG-4): it goes to the chain directly and
23
+ # never back through the tool surface.
24
+ module Recorder
25
+ module_function
26
+
27
+ SALT_BYTES = 16
28
+ COMMITMENT_DOMAIN = 'cg-1/content'
29
+
30
+ # Injectable chain factory so design-constraint tests can redirect
31
+ # records away from the live chain store.
32
+ @chain_factory = nil
33
+
34
+ def chain_factory=(callable)
35
+ @chain_factory = callable
36
+ end
37
+
38
+ def chain
39
+ if @chain_factory
40
+ @chain_factory.call
41
+ else
42
+ # In-server the chain class is already loaded; the require path
43
+ # is a template-layout fallback for standalone runs (tests).
44
+ unless defined?(KairosMcp::KairosChain::Chain)
45
+ require_relative '../../../../lib/kairos_mcp/kairos_chain/chain'
46
+ end
47
+ KairosMcp::KairosChain::Chain.new
48
+ end
49
+ end
50
+
51
+ def commitment(content_json)
52
+ salt = SecureRandom.hex(SALT_BYTES)
53
+ digest = Digest::SHA256.hexdigest("#{COMMITMENT_DOMAIN}|#{salt}|#{content_json}")
54
+ { salt: salt, digest: digest }
55
+ end
56
+
57
+ # Re-derivation check (CG-4): given a record's digest, the salt from
58
+ # the operator report, and a re-presentation of the content, verify
59
+ # the commitment binds. The record alone reconstructs nothing.
60
+ def commitment_valid?(digest, salt, content_json)
61
+ Digest::SHA256.hexdigest("#{COMMITMENT_DOMAIN}|#{salt}|#{content_json}") == digest
62
+ end
63
+
64
+ def record_decision(verdict_result, commitment_digest)
65
+ entry = {
66
+ 'type' => 'cg_guard_decision',
67
+ 'verdict' => verdict_result[:verdict],
68
+ 'rule' => verdict_result[:rule],
69
+ 'crossing' => descriptor_fields(verdict_result[:crossing]),
70
+ 'policy_sha256' => verdict_result[:basis][:policy_sha256],
71
+ 'engine' => verdict_result[:basis][:engine],
72
+ 'commitment' => commitment_digest
73
+ }
74
+ append(entry)
75
+ end
76
+
77
+ def record_regime_event(event, policy)
78
+ append(
79
+ 'type' => 'cg_guard_regime',
80
+ 'event' => event,
81
+ 'policy_sha256' => policy&.sha256,
82
+ 'engine' => KairosMcp::SkillSets::ConfidentialityGuard::Policy::ENGINE_VERSION,
83
+ 'profile' => policy&.profile_path ? File.basename(policy.profile_path) : nil
84
+ )
85
+ end
86
+
87
+ def record_policy_edit(descriptor, commitment_digest, policy)
88
+ append(
89
+ 'type' => 'cg_policy_edit',
90
+ 'tool' => descriptor[:tool],
91
+ 'path' => File.basename(descriptor[:path].to_s),
92
+ 'pinned_sha256' => policy.sha256,
93
+ 'commitment' => commitment_digest
94
+ )
95
+ end
96
+
97
+ def append(entry)
98
+ chain.add_block([JSON.generate(entry)])
99
+ end
100
+
101
+ # Record fields are identifiers, versions, descriptors, and
102
+ # commitments — never content (§7). Storage-read records carry the
103
+ # designation id, not the raw caller-supplied path (which can
104
+ # itself be sensitive); the path is bound by the commitment.
105
+ def descriptor_fields(descriptor)
106
+ fields = { 'class' => descriptor[:class].to_s, 'tool' => descriptor[:tool] }
107
+ fields['layer'] = descriptor[:layer] if descriptor[:layer]
108
+ fields['designation'] = descriptor[:designation] if descriptor[:designation]
109
+ fields
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end