lex-identity 0.2.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 +7 -0
- data/Gemfile +8 -0
- data/lex-identity.gemspec +30 -0
- data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
- data/lib/legion/extensions/identity/client.rb +23 -0
- data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
- data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
- data/lib/legion/extensions/identity/runners/entra.rb +223 -0
- data/lib/legion/extensions/identity/runners/identity.rb +86 -0
- data/lib/legion/extensions/identity/version.rb +9 -0
- data/lib/legion/extensions/identity.rb +24 -0
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
- data/spec/legion/extensions/identity/client_spec.rb +32 -0
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
- data/spec/legion/extensions/identity/runners/entra_spec.rb +405 -0
- data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
- data/spec/local_persistence_spec.rb +329 -0
- data/spec/spec_helper.rb +33 -0
- metadata +95 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Runners
|
|
7
|
+
module Identity
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def observe_behavior(dimension:, value:, **)
|
|
12
|
+
fingerprint = identity_fingerprint
|
|
13
|
+
fingerprint.observe(dimension, value)
|
|
14
|
+
|
|
15
|
+
Legion::Logging.debug "[identity] observe: dim=#{dimension} val=#{value.round(2)} " \
|
|
16
|
+
"obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
|
|
17
|
+
{
|
|
18
|
+
dimension: dimension,
|
|
19
|
+
recorded: true,
|
|
20
|
+
observation_count: fingerprint.observation_count,
|
|
21
|
+
maturity: fingerprint.maturity
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def observe_all(observations:, **)
|
|
26
|
+
fingerprint = identity_fingerprint
|
|
27
|
+
fingerprint.observe_all(observations)
|
|
28
|
+
|
|
29
|
+
Legion::Logging.debug "[identity] observe_all: dims=#{observations.keys.join(',')} " \
|
|
30
|
+
"obs=#{fingerprint.observation_count} maturity=#{fingerprint.maturity}"
|
|
31
|
+
{
|
|
32
|
+
dimensions_observed: observations.keys,
|
|
33
|
+
observation_count: fingerprint.observation_count,
|
|
34
|
+
maturity: fingerprint.maturity
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_entropy(observations: {}, **)
|
|
39
|
+
fingerprint = identity_fingerprint
|
|
40
|
+
entropy = fingerprint.current_entropy(observations)
|
|
41
|
+
classification = Helpers::Dimensions.classify_entropy(entropy)
|
|
42
|
+
trend = fingerprint.entropy_trend
|
|
43
|
+
|
|
44
|
+
result = {
|
|
45
|
+
entropy: entropy,
|
|
46
|
+
classification: classification,
|
|
47
|
+
trend: trend,
|
|
48
|
+
in_range: Helpers::Dimensions::OPTIMAL_ENTROPY_RANGE.cover?(entropy)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case classification
|
|
52
|
+
when :high_entropy
|
|
53
|
+
result[:warning] = :possible_impersonation_or_drift
|
|
54
|
+
result[:action] = :enter_caution_mode
|
|
55
|
+
Legion::Logging.warn "[identity] high entropy detected: #{entropy.round(3)} trend=#{trend} - possible impersonation"
|
|
56
|
+
when :low_entropy
|
|
57
|
+
result[:warning] = :possible_automation
|
|
58
|
+
result[:action] = :trigger_verification
|
|
59
|
+
Legion::Logging.warn "[identity] low entropy detected: #{entropy.round(3)} trend=#{trend} - possible automation"
|
|
60
|
+
else
|
|
61
|
+
Legion::Logging.debug "[identity] entropy check: #{entropy.round(3)} classification=#{classification} trend=#{trend}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def identity_status(**)
|
|
68
|
+
fingerprint = identity_fingerprint
|
|
69
|
+
Legion::Logging.debug "[identity] status: maturity=#{fingerprint.maturity} observations=#{fingerprint.observation_count}"
|
|
70
|
+
fingerprint.to_h
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def identity_maturity(**)
|
|
74
|
+
{ maturity: identity_fingerprint.maturity }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def identity_fingerprint
|
|
80
|
+
@identity_fingerprint ||= Helpers::Fingerprint.new
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/version'
|
|
4
|
+
require 'legion/extensions/identity/helpers/dimensions'
|
|
5
|
+
require 'legion/extensions/identity/helpers/fingerprint'
|
|
6
|
+
require 'legion/extensions/identity/helpers/vault_secrets'
|
|
7
|
+
require 'legion/extensions/identity/runners/identity'
|
|
8
|
+
require 'legion/extensions/identity/runners/entra'
|
|
9
|
+
require 'legion/extensions/identity/actors/orphan_check'
|
|
10
|
+
|
|
11
|
+
module Legion
|
|
12
|
+
module Extensions
|
|
13
|
+
module Identity
|
|
14
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if defined?(Legion::Data::Local)
|
|
20
|
+
Legion::Data::Local.register_migrations(
|
|
21
|
+
name: :identity,
|
|
22
|
+
path: File.join(__dir__, 'identity', 'local_migrations')
|
|
23
|
+
)
|
|
24
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub the framework actor base class since legionio gem is not available in test
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Actors
|
|
7
|
+
class Every # rubocop:disable Lint/EmptyClass
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Intercept the require in the actor file so it doesn't fail
|
|
14
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every'
|
|
15
|
+
|
|
16
|
+
require 'legion/extensions/identity/actors/orphan_check'
|
|
17
|
+
|
|
18
|
+
RSpec.describe Legion::Extensions::Identity::Actor::OrphanCheck do
|
|
19
|
+
subject(:actor) { described_class.new }
|
|
20
|
+
|
|
21
|
+
describe 'ORPHAN_CHECK_INTERVAL' do
|
|
22
|
+
it 'is 14400 seconds (4 hours)' do
|
|
23
|
+
expect(described_class::ORPHAN_CHECK_INTERVAL).to eq(14_400)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#runner_class' do
|
|
28
|
+
it 'returns the Entra module' do
|
|
29
|
+
expect(actor.runner_class).to eq(Legion::Extensions::Identity::Runners::Entra)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#runner_function' do
|
|
34
|
+
it 'returns check_orphans' do
|
|
35
|
+
expect(actor.runner_function).to eq('check_orphans')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#time' do
|
|
40
|
+
it 'returns 14400 seconds using the constant' do
|
|
41
|
+
expect(actor.time).to eq(Legion::Extensions::Identity::Actor::OrphanCheck::ORPHAN_CHECK_INTERVAL)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'returns 14400' do
|
|
45
|
+
expect(actor.time).to eq(14_400)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '#use_runner?' do
|
|
50
|
+
it 'returns false' do
|
|
51
|
+
expect(actor.use_runner?).to be false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#check_subtask?' do
|
|
56
|
+
it 'returns false' do
|
|
57
|
+
expect(actor.check_subtask?).to be false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe '#generate_task?' do
|
|
62
|
+
it 'returns false' do
|
|
63
|
+
expect(actor.generate_task?).to be false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#enabled?' do
|
|
68
|
+
context 'when Legion::Data is not defined' do
|
|
69
|
+
it 'returns false' do
|
|
70
|
+
hide_const('Legion::Data') if defined?(Legion::Data)
|
|
71
|
+
expect(actor.enabled?).to be_falsey
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
context 'when Legion::Data is defined and data is connected' do
|
|
76
|
+
it 'returns truthy' do
|
|
77
|
+
stub_const('Legion::Data', Module.new)
|
|
78
|
+
stub_const('Legion::Settings', Class.new)
|
|
79
|
+
settings_double = { connected: true }
|
|
80
|
+
allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
|
|
81
|
+
expect(actor.enabled?).to be_truthy
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
context 'when Legion::Data is defined but connected is false' do
|
|
86
|
+
it 'returns false' do
|
|
87
|
+
stub_const('Legion::Data', Module.new)
|
|
88
|
+
stub_const('Legion::Settings', Class.new)
|
|
89
|
+
settings_double = { connected: false }
|
|
90
|
+
allow(Legion::Settings).to receive(:[]).with(:data).and_return(settings_double)
|
|
91
|
+
expect(actor.enabled?).to be false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'when Legion::Settings raises an error' do
|
|
96
|
+
it 'returns false' do
|
|
97
|
+
stub_const('Legion::Data', Module.new)
|
|
98
|
+
stub_const('Legion::Settings', Class.new)
|
|
99
|
+
allow(Legion::Settings).to receive(:[]).with(:data).and_raise(StandardError)
|
|
100
|
+
expect(actor.enabled?).to be false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Identity::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to identity runner methods' do
|
|
9
|
+
expect(client).to respond_to(:observe_behavior)
|
|
10
|
+
expect(client).to respond_to(:observe_all)
|
|
11
|
+
expect(client).to respond_to(:check_entropy)
|
|
12
|
+
expect(client).to respond_to(:identity_status)
|
|
13
|
+
expect(client).to respond_to(:identity_maturity)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'round-trips identity lifecycle' do
|
|
17
|
+
# Build identity
|
|
18
|
+
50.times do
|
|
19
|
+
client.observe_all(observations: {
|
|
20
|
+
communication_cadence: 0.5 + (rand * 0.1),
|
|
21
|
+
vocabulary_patterns: 0.6 + (rand * 0.1),
|
|
22
|
+
emotional_response: 0.4 + (rand * 0.1)
|
|
23
|
+
})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
expect(client.identity_maturity[:maturity]).to eq(:established)
|
|
27
|
+
|
|
28
|
+
# Check entropy with consistent behavior
|
|
29
|
+
result = client.check_entropy(observations: { communication_cadence: 0.55 })
|
|
30
|
+
expect(result[:entropy]).to be_between(0.0, 1.0)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Identity::Helpers::Dimensions do
|
|
4
|
+
describe '.new_identity_model' do
|
|
5
|
+
it 'creates a model with 6 dimensions' do
|
|
6
|
+
model = described_class.new_identity_model
|
|
7
|
+
expect(model.size).to eq(6)
|
|
8
|
+
described_class::IDENTITY_DIMENSIONS.each do |dim|
|
|
9
|
+
expect(model[dim][:mean]).to eq(0.5)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '.compute_entropy' do
|
|
15
|
+
it 'returns 0.5 for empty observations' do
|
|
16
|
+
model = described_class.new_identity_model
|
|
17
|
+
expect(described_class.compute_entropy({}, model)).to eq(0.5)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'returns low entropy for observations matching baseline' do
|
|
21
|
+
model = described_class.new_identity_model
|
|
22
|
+
model[:communication_cadence][:observations] = 50
|
|
23
|
+
obs = { communication_cadence: 0.5 }
|
|
24
|
+
entropy = described_class.compute_entropy(obs, model)
|
|
25
|
+
expect(entropy).to be < 0.3
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'returns high entropy for observations diverging from baseline' do
|
|
29
|
+
model = described_class.new_identity_model
|
|
30
|
+
model[:communication_cadence][:observations] = 50
|
|
31
|
+
model[:communication_cadence][:variance] = 0.1
|
|
32
|
+
obs = { communication_cadence: 1.0 }
|
|
33
|
+
entropy = described_class.compute_entropy(obs, model)
|
|
34
|
+
expect(entropy).to be > 0.3
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '.classify_entropy' do
|
|
39
|
+
it 'classifies high entropy' do
|
|
40
|
+
expect(described_class.classify_entropy(0.8)).to eq(:high_entropy)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'classifies low entropy' do
|
|
44
|
+
expect(described_class.classify_entropy(0.1)).to eq(:low_entropy)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'classifies normal entropy' do
|
|
48
|
+
expect(described_class.classify_entropy(0.5)).to eq(:normal)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Identity::Helpers::Fingerprint do
|
|
4
|
+
let(:fp) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#observe' do
|
|
7
|
+
it 'records observation for valid dimension' do
|
|
8
|
+
fp.observe(:communication_cadence, 0.7)
|
|
9
|
+
expect(fp.observation_count).to eq(1)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'ignores invalid dimensions' do
|
|
13
|
+
fp.observe(:nonexistent, 0.5)
|
|
14
|
+
expect(fp.observation_count).to eq(0)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'shifts mean toward observed values' do
|
|
18
|
+
original = fp.model[:vocabulary_patterns][:mean]
|
|
19
|
+
10.times { fp.observe(:vocabulary_patterns, 0.9) }
|
|
20
|
+
expect(fp.model[:vocabulary_patterns][:mean]).to be > original
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#observe_all' do
|
|
25
|
+
it 'records multiple dimensions at once' do
|
|
26
|
+
fp.observe_all(communication_cadence: 0.6, vocabulary_patterns: 0.7)
|
|
27
|
+
expect(fp.observation_count).to eq(2)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '#current_entropy' do
|
|
32
|
+
it 'computes entropy against model' do
|
|
33
|
+
entropy = fp.current_entropy(communication_cadence: 0.5)
|
|
34
|
+
expect(entropy).to be_between(0.0, 1.0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'tracks entropy history' do
|
|
38
|
+
3.times { fp.current_entropy(communication_cadence: 0.5) }
|
|
39
|
+
expect(fp.entropy_history.size).to eq(3)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#entropy_trend' do
|
|
44
|
+
it 'returns stable for insufficient data' do
|
|
45
|
+
expect(fp.entropy_trend).to eq(:stable)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'detects rising entropy' do
|
|
49
|
+
5.times { |i| fp.current_entropy(communication_cadence: 0.5 + (i * 0.1)) }
|
|
50
|
+
# Trend depends on actual computed values
|
|
51
|
+
trend = fp.entropy_trend(window: 5)
|
|
52
|
+
expect(%i[rising stable falling]).to include(trend)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#maturity' do
|
|
57
|
+
it 'starts as nascent' do
|
|
58
|
+
expect(fp.maturity).to eq(:nascent)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'progresses to developing' do
|
|
62
|
+
15.times { fp.observe(:communication_cadence, 0.5) }
|
|
63
|
+
expect(fp.maturity).to eq(:developing)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|