From caac67cb3f04f2a09eac0b97f1382cf7677230cd Mon Sep 17 00:00:00 2001 From: Esity Date: Mon, 18 May 2026 23:35:16 -0500 Subject: [PATCH] Enforce identity from Legion::Identity::Process on all writes and reads Writes: handle_ingest now derives identity_principal_id, identity_id, and identity_canonical_name from Legion::Identity::Process instead of accepting them via kwargs. This prevents privilege escalation through tool calls or AMQP messages that could spoof identity. Reads: handle_query and retrieve_relevant auto-inject requesting_principal_id from Legion::Identity::Process when not explicitly provided, enabling access_scope filtering by default. --- .../extensions/apollo/runners/knowledge.rb | 37 ++++++++++++++++--- lib/legion/extensions/apollo/version.rb | 2 +- .../apollo/runners/knowledge_spec.rb | 24 ++++++------ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/lib/legion/extensions/apollo/runners/knowledge.rb b/lib/legion/extensions/apollo/runners/knowledge.rb index b1ffd24..d3201ea 100644 --- a/lib/legion/extensions/apollo/runners/knowledge.rb +++ b/lib/legion/extensions/apollo/runners/knowledge.rb @@ -85,10 +85,10 @@ def deprecate_entry(entry_id:, reason:, **) def handle_ingest(content: nil, content_type: nil, tags: [], source_agent: 'unknown', # rubocop:disable Metrics/ParameterLists source_provider: nil, source_channel: nil, knowledge_domain: nil, submitted_by: nil, submitted_from: nil, content_hash: nil, context: {}, - skip: false, access_scope: 'global', identity_principal_id: nil, - identity_id: nil, identity_canonical_name: nil, **) + skip: false, access_scope: 'global', **) return { status: :skipped } if skip + identity = resolve_process_identity content = normalize_text_input(content) content_type = normalize_content_type(content_type.nil? ? :observation : content_type) log.debug("Apollo Knowledge.handle_ingest content_length=#{content.length} content_type=#{content_type} tags=#{Array(tags).size} source_agent=#{source_agent} source_channel=#{source_channel || 'nil'}") # rubocop:disable Layout/LineLength @@ -97,7 +97,7 @@ def handle_ingest(content: nil, content_type: nil, tags: [], source_agent: 'unkn hash = content_hash || (defined?(Helpers::Writeback) ? Helpers::Writeback.content_hash(content) : nil) existing = active_duplicate_for_hash(hash, access_scope: access_scope, - identity_principal_id: identity_principal_id) + identity_principal_id: identity[:principal_id]) if existing log.info("Apollo Knowledge.handle_ingest deduped entry_id=#{existing.id} source_agent=#{source_agent}") return { success: true, entry_id: existing.id, deduped: true } @@ -109,9 +109,9 @@ def handle_ingest(content: nil, content_type: nil, tags: [], source_agent: 'unkn source_provider: source_provider, source_channel: source_channel, submitted_by: submitted_by, submitted_from: submitted_from, access_scope: access_scope, - identity_principal_id: identity_principal_id, - identity_id: identity_id, - identity_canonical_name: identity_canonical_name) + identity_principal_id: identity[:principal_id], + identity_id: identity[:identity_id], + identity_canonical_name: identity[:canonical_name]) corroborated, existing_id = find_corroboration( embedding, content_type_sym, metadata[:source_agent], metadata[:source_channel] @@ -148,6 +148,7 @@ def handle_query(query:, limit: Helpers::GraphQuery.default_query_limit, # ruboc requesting_principal_id: nil, **) return { success: false, error: 'apollo_data_not_available' } unless Helpers::DataModels.apollo_entry_available? + requesting_principal_id = resolve_requesting_principal_id(requesting_principal_id) entry_model = Helpers::DataModels.apollo_entry query = normalize_text_input(query) status_defaulted = status.equal?(UNSET) @@ -273,6 +274,7 @@ def retrieve_relevant(query: nil, limit: Helpers::Confidence.apollo_setting(:que return { success: false, error: 'apollo_data_not_available' } unless Helpers::DataModels.apollo_entry_available? + requesting_principal_id = resolve_requesting_principal_id(requesting_principal_id) query = normalize_text_input(query) log.debug("Apollo Knowledge.retrieve_relevant query_length=#{query.length} limit=#{limit} min_confidence=#{min_confidence} tags=#{Array(tags).size} domain=#{domain || 'nil'}") # rubocop:disable Layout/LineLength return { success: true, entries: [], count: 0 } if query.nil? || query.to_s.strip.empty? @@ -720,6 +722,29 @@ def upsert_expertise(source_agent:, domain:) end end + def resolve_process_identity + return { principal_id: nil, identity_id: nil, canonical_name: nil } unless defined?(Legion::Identity::Process) + + { + principal_id: Legion::Identity::Process.db_principal_id, + identity_id: Legion::Identity::Process.db_identity_id, + canonical_name: Legion::Identity::Process.canonical_name + } + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'apollo.knowledge.resolve_process_identity') + { principal_id: nil, identity_id: nil, canonical_name: nil } + end + + def resolve_requesting_principal_id(explicit_value) + return explicit_value if explicit_value + return nil unless defined?(Legion::Identity::Process) + + Legion::Identity::Process.db_principal_id + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'apollo.knowledge.resolve_requesting_principal_id') + nil + end + include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex) include Legion::JSON::Helper include Legion::Settings::Helper diff --git a/lib/legion/extensions/apollo/version.rb b/lib/legion/extensions/apollo/version.rb index 4367ce7..9b1f9fa 100644 --- a/lib/legion/extensions/apollo/version.rb +++ b/lib/legion/extensions/apollo/version.rb @@ -3,7 +3,7 @@ module Legion module Extensions module Apollo - VERSION = '0.4.27' + VERSION = '0.4.28' end end end diff --git a/spec/legion/extensions/apollo/runners/knowledge_spec.rb b/spec/legion/extensions/apollo/runners/knowledge_spec.rb index 3973aa2..cad05d7 100644 --- a/spec/legion/extensions/apollo/runners/knowledge_spec.rb +++ b/spec/legion/extensions/apollo/runners/knowledge_spec.rb @@ -371,7 +371,7 @@ def self.generate(*, **) end end - context 'identity kwargs persistence' do + context 'identity from Legion::Identity::Process' do let(:mock_entry_class2) { double('ApolloEntry2') } let(:mock_expertise_class2) { double('ApolloExpertise2') } let(:mock_access_log_class2) { double('ApolloAccessLog2') } @@ -390,18 +390,20 @@ def self.generate(*, **) allow(host).to receive(:embed_text).and_return(nil) allow(host).to receive(:find_corroboration).and_return([false, nil]) allow(host).to receive(:detect_contradictions).and_return([]) + stub_const('Legion::Identity::Process', double( + db_principal_id: 42, + db_identity_id: 7, + canonical_name: 'alice' + )) end - it 'passes identity_principal_id and access_scope through to create_candidate_entry' do + it 'derives identity from Legion::Identity::Process and persists it' do expect(mock_entry_class2).to receive(:create).with( - hash_including(identity_principal_id: 42, access_scope: 'private') + hash_including(identity_principal_id: 42, identity_id: 7, identity_canonical_name: 'alice', + access_scope: 'private') ).and_return(mock_entry2) - host.handle_ingest( - content: 'test fact', tags: [], - identity_principal_id: 42, identity_id: 7, identity_canonical_name: 'alice', - access_scope: 'private' - ) + host.handle_ingest(content: 'test fact', tags: [], access_scope: 'private') end it 'defaults access_scope to global when not provided' do @@ -412,14 +414,14 @@ def self.generate(*, **) host.handle_ingest(content: 'test fact', tags: []) end - it 'persists identity_id and identity_canonical_name' do + it 'ignores identity kwargs passed by callers' do expect(mock_entry_class2).to receive(:create).with( - hash_including(identity_id: 7, identity_canonical_name: 'alice') + hash_including(identity_principal_id: 42, identity_id: 7, identity_canonical_name: 'alice') ).and_return(mock_entry2) host.handle_ingest( content: 'test fact', tags: [], - identity_principal_id: 42, identity_id: 7, identity_canonical_name: 'alice', + identity_principal_id: 999, identity_id: 888, identity_canonical_name: 'mallory', access_scope: 'private' ) end