kairos-chain 3.47.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/lib/kairos_mcp/skillset.rb +10 -0
- data/lib/kairos_mcp/tool_registry.rb +42 -9
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/confidentiality_guard/config/confidentiality_guard.yml +18 -0
- data/templates/skillsets/confidentiality_guard/config/profile.example.yml +34 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/canon.rb +52 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/policy.rb +144 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/recorder.rb +114 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/regime.rb +318 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/surfaces.rb +110 -0
- data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/verdict.rb +86 -0
- data/templates/skillsets/confidentiality_guard/skillset.json +20 -0
- data/templates/skillsets/confidentiality_guard/test/test_cg_guard.rb +566 -0
- data/templates/skillsets/confidentiality_guard/tools/cg_status.rb +51 -0
- data/templates/skillsets/synoptis/bin/sda_verify.rb +260 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/aggregate_disclosure.rb +221 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/ec_group.rb +196 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/pedersen.rb +133 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/range_proof.rb +266 -0
- data/templates/skillsets/synoptis/test/test_sda_aggregate.rb +214 -0
- data/templates/skillsets/synoptis/test/test_sda_range_proof.rb +238 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69d783a71fb5c56c3b7bfc549695f337ad9099d2db5beaf0ae557b845f854a2f
|
|
4
|
+
data.tar.gz: 3f858ee398aa3ecbe24c664e5618653ab1bfd38f6a5a01c6779270cefc4d7e68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16e67612af99d2dc86f85d9d128a3e5906f07f72677f7a8ff547685b9106e1c62b3dea2161ce776fcd68819676f7b665b8b8a9e127a2e69c6cc33831685aab60
|
|
7
|
+
data.tar.gz: b202b151145f3bb7c1f1383450129d8b3455f0eeb3d9fa7716b3dea173d9c3eb06a56d3fa90b3236b0e8585cb88a3e2276707512e8cd06c833f64efd6ad7915a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,77 @@ 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
|
+
|
|
7
78
|
## [3.47.0] - 2026-07-22
|
|
8
79
|
|
|
9
80
|
### Synoptis — selective disclosure sdp-1 (AUD-L4 slice 1)
|
data/lib/kairos_mcp/skillset.rb
CHANGED
|
@@ -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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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}"
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|