legionio 1.4.29 → 1.4.44

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +104 -0
  4. data/CLAUDE.md +8 -4
  5. data/lib/legion/api/auth.rb +82 -0
  6. data/lib/legion/api/auth_worker.rb +104 -0
  7. data/lib/legion/api/gaia.rb +22 -0
  8. data/lib/legion/api/middleware/auth.rb +1 -1
  9. data/lib/legion/api/webhooks.rb +29 -0
  10. data/lib/legion/api.rb +4 -0
  11. data/lib/legion/audit/hash_chain.rb +32 -0
  12. data/lib/legion/audit/siem_export.rb +33 -0
  13. data/lib/legion/catalog.rb +47 -0
  14. data/lib/legion/chat/notification_bridge.rb +2 -0
  15. data/lib/legion/chat/skills.rb +49 -0
  16. data/lib/legion/cli/chat/progress_bar.rb +55 -0
  17. data/lib/legion/cli/chat/team.rb +43 -0
  18. data/lib/legion/cli/chat_command.rb +19 -1
  19. data/lib/legion/cli/cost/data_client.rb +53 -0
  20. data/lib/legion/cli/cost_command.rb +76 -0
  21. data/lib/legion/cli/lex_templates.rb +64 -0
  22. data/lib/legion/cli/marketplace_command.rb +79 -0
  23. data/lib/legion/cli/mcp_command.rb +12 -1
  24. data/lib/legion/cli/notebook_command.rb +69 -0
  25. data/lib/legion/cli/skill_command.rb +86 -0
  26. data/lib/legion/cli.rb +16 -0
  27. data/lib/legion/context.rb +53 -0
  28. data/lib/legion/docs/site_generator.rb +48 -0
  29. data/lib/legion/guardrails.rb +50 -0
  30. data/lib/legion/isolation.rb +49 -0
  31. data/lib/legion/mcp/auth.rb +50 -0
  32. data/lib/legion/mcp/server.rb +8 -2
  33. data/lib/legion/mcp/tool_governance.rb +77 -0
  34. data/lib/legion/mcp.rb +9 -0
  35. data/lib/legion/registry/security_scanner.rb +48 -0
  36. data/lib/legion/registry.rb +72 -0
  37. data/lib/legion/sandbox.rb +65 -0
  38. data/lib/legion/version.rb +1 -1
  39. data/lib/legion/webhooks.rb +124 -0
  40. metadata +26 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b82509c414c56ab775cbaa5879a5312e9d3fd69226eadae308c52f5cba2bff25
4
- data.tar.gz: c82590fb0283ee9db793c5b18751dbba8eb51c209351e2674a2ac542e875b718
3
+ metadata.gz: 16599e1c095434bfb65c5a5aa763cb821324e192f8e367cad1aff64fadec7e10
4
+ data.tar.gz: 2747877e468f15212ed3bbd19eaa287323d159a676a01e80e6b1ca11e0abed9b
5
5
  SHA512:
6
- metadata.gz: eff59d6453668b41bfa4047f77db11d02dc3fadacd59801d94fc1a7ec4af14598e30d817e0f075631b087a195e1715a68744a0e399fec728c0d426d8b3dc20e6
7
- data.tar.gz: 10f935f6b10a6db5ef4df40e84a487b5fac01a10bc4789a3eca257aed77294a804309eccca224c70b5a3b082de5e0338408464a2dacb1fc353ef43fa54e128fd
6
+ metadata.gz: e522c7f831673baa38cf887b1493e78e7133d930f3e857b82fefddbeeffbcc88877e4d741330a222e744170bd0e5876ec08deca5c8a00d8dc6cc520fbd0f54dc
7
+ data.tar.gz: 802b45c5a8d13d79f0c52ac872080a6fde0e5b64bc7ee0f46a21fbdb5be56c23ddad2f6b242b535b9a862e13b446735cb57c44ac50a9a0b021a728c620defe20
data/.rubocop.yml CHANGED
@@ -36,6 +36,8 @@ Metrics/BlockLength:
36
36
  - 'lib/legion/cli/gaia_command.rb'
37
37
  - 'lib/legion/cli/schedule_command.rb'
38
38
  - 'lib/legion/cli/update_command.rb'
39
+ - 'lib/legion/api/auth.rb'
40
+ - 'lib/legion/api/auth_worker.rb'
39
41
 
40
42
  Metrics/AbcSize:
41
43
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.44] - 2026-03-17
4
+
5
+ ### Added
6
+ - `POST /api/auth/worker-token`: Entra client credentials token exchange endpoint (validates client_credentials grant via JWKS, looks up worker by appid, issues scoped Legion worker JWT)
7
+ - Auth middleware SKIP_PATHS now includes `/api/auth/token` and `/api/auth/worker-token`
8
+
9
+ ## [1.4.43] - 2026-03-17
10
+
11
+ ### Fixed
12
+ - Auth token exchange route used `Legion::Settings.dig` which doesn't exist — replaced with bracket access
13
+ - Auth spec required `legion/rbac` gem directly — replaced with inline stub for standalone test execution
14
+
15
+ ## [1.4.42] - 2026-03-17
16
+
17
+ ### Added
18
+ - `POST /api/auth/token`: Entra ID token exchange endpoint (validates external JWT via JWKS, maps claims via EntraClaimsMapper, issues Legion token)
19
+
20
+ ## [1.4.41] - 2026-03-17
21
+
22
+ ### Added
23
+ - `Legion::CLI::LexTemplates`: extension template registry (basic, llm-agent, service-integration, scheduled-task, webhook-handler)
24
+ - `Legion::Docs::SiteGenerator`: documentation site generation from existing markdown files
25
+
26
+ ## [1.4.40] - 2026-03-17
27
+
28
+ ### Added
29
+ - `Legion::Guardrails`: embedding similarity and RAG relevancy safety checks
30
+ - `Legion::Context`: session/user tracking with thread-local `SessionContext`
31
+ - `Legion::Catalog`: AI catalog registration for MCP tools and workers
32
+
33
+ ## [1.4.39] - 2026-03-17
34
+
35
+ ### Added
36
+ - `Legion::Webhooks`: outbound webhook dispatcher with HMAC-SHA256 signing
37
+ - Webhook registration, delivery tracking, and dead letter queue
38
+ - API routes: `GET/POST/DELETE /api/webhooks`
39
+
40
+ ## [1.4.38] - 2026-03-17
41
+
42
+ ### Added
43
+ - `Legion::Isolation`: per-agent data and tool access enforcement with thread-local context
44
+ - `Isolation::Context`: tool allowlist, data filter, and risk tier per agent
45
+
46
+ ## [1.4.37] - 2026-03-17
47
+
48
+ ### Added
49
+ - `POST /api/channels/teams/webhook`: Bot Framework activity delivery to GAIA sensory buffer
50
+
51
+ ## [1.4.36] - 2026-03-17
52
+
53
+ ### Added
54
+ - `Audit::HashChain`: SHA-256 hash chain for tamper-evident audit records
55
+ - `Audit::SiemExport`: SIEM-compatible JSON and NDJSON export with integrity metadata
56
+ - `Audit::HashChain.verify_chain` validates hash chain between records
57
+
58
+ ## [1.4.35] - 2026-03-17
59
+
60
+ ### Added
61
+ - `Chat::Team`: multi-user context tracking with thread-local user, env detection
62
+ - `Chat::ProgressBar`: progress indicator for long-running operations with ETA
63
+ - `legion notebook read/export`: Jupyter notebook reading and export (markdown/script)
64
+
65
+ ## [1.4.34] - 2026-03-17
66
+
67
+ ### Added
68
+ - `Legion::Registry`: central extension metadata store with search, risk tier filtering, AIRB status
69
+ - `Legion::Sandbox`: capability-based extension sandboxing with enforcement toggle
70
+ - `Legion::Registry::SecurityScanner`: naming convention, checksum, and gemspec metadata validation
71
+ - `legion marketplace`: CLI for search, info, list, scan operations
72
+
73
+ ## [1.4.33] - 2026-03-17
74
+
75
+ ### Added
76
+ - `legion cost summary`: overall cost summary (today/week/month)
77
+ - `legion cost worker <id>`: per-worker cost breakdown
78
+ - `legion cost top`: top cost consumers ranked by spend
79
+ - `legion cost export`: export cost data as JSON or CSV
80
+ - `Legion::CLI::CostData::Client`: API client for cost data retrieval
81
+
82
+ ### Fixed
83
+ - `Connection.resolve_config_dir` spec now correctly stubs `~/.legionio/settings` path
84
+
85
+ ## [1.4.32] - 2026-03-17
86
+
87
+ ### Fixed
88
+ - `NotificationBridge` missing `require_relative 'notification_queue'` causing `NameError` on `legion chat`
89
+
90
+ ## [1.4.31] - 2026-03-16
91
+
92
+ ### Added
93
+ - Skills system: `.legion/skills/` and `~/.legionio/skills/` YAML frontmatter markdown files
94
+ - `Legion::Chat::Skills`: discovery, parsing, and find for skill files
95
+ - `/skill-name` invocation in chat resolves user-defined skills
96
+ - `legion skill list`, `legion skill show`, `legion skill create`, `legion skill run` CLI subcommands
97
+
98
+ ## [1.4.30] - 2026-03-16
99
+
100
+ ### Added
101
+ - `MCP::Auth`: token-based MCP authentication (JWT + API key)
102
+ - `MCP::ToolGovernance`: risk-tier-aware tool filtering and invocation audit
103
+ - `MCP.server_for(token:)` builds identity-scoped MCP server instances
104
+ - HTTP transport auth: Bearer token validation with 401 response on failure
105
+ - MCP settings: `mcp.auth.enabled`, `mcp.auth.allowed_api_keys`, `mcp.governance.enabled`, `mcp.governance.tool_risk_tiers`
106
+
3
107
  ## [1.4.29] - 2026-03-16
4
108
 
5
109
  ### Added
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.4.29
12
+ **Version**: 1.4.36
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -481,8 +481,10 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
481
481
  | `lib/legion/api/middleware/body_limit.rb` | BodyLimit: request body size limit (1MB max, returns 413) |
482
482
  | `lib/legion/api/middleware/rate_limit.rb` | RateLimit: sliding-window rate limiting with per-IP/agent/tenant tiers |
483
483
  | **MCP** | |
484
- | `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory |
485
- | `lib/legion/mcp/server.rb` | MCP::Server builder, TOOL_CLASSES array, instructions |
484
+ | `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory, `server_for(token:)` |
485
+ | `lib/legion/mcp/auth.rb` | MCP authentication: JWT + API key verification |
486
+ | `lib/legion/mcp/tool_governance.rb` | Risk-tier tool filtering and invocation audit |
487
+ | `lib/legion/mcp/server.rb` | MCP::Server builder, TOOL_CLASSES array, governance-aware build |
486
488
  | `lib/legion/digital_worker.rb` | DigitalWorker module entry point |
487
489
  | `lib/legion/digital_worker/lifecycle.rb` | Worker state machine |
488
490
  | `lib/legion/digital_worker/registry.rb` | In-process worker registry |
@@ -564,6 +566,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
564
566
  | `API::Routes::Relationships` | Fully implemented (backed by legion-data migration 013) |
565
567
  | `API::Routes::Chains` | 501 stub - no data model |
566
568
  | `API::Middleware::Auth` | JWT Bearer auth middleware — real token validation and API key (`X-API-Key` header) auth both implemented |
569
+ | `MCP::Auth` | JWT + API key authentication for MCP server (HTTP transport) |
570
+ | `MCP::ToolGovernance` | Risk-tier tool filtering + audit — disabled by default, opt-in via settings |
567
571
  | `legion-data` chains/relationships models | Not yet implemented |
568
572
 
569
573
  ## Rubocop Notes
@@ -577,7 +581,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
577
581
 
578
582
  ```bash
579
583
  bundle install
580
- bundle exec rspec # 997 examples, 0 failures
584
+ bundle exec rspec # 1088 examples, 0 failures
581
585
  bundle exec rubocop # 0 offenses
582
586
  ```
583
587
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Auth
7
+ def self.registered(app)
8
+ register_token_exchange(app)
9
+ end
10
+
11
+ def self.register_token_exchange(app) # rubocop:disable Metrics/MethodLength
12
+ app.post '/api/auth/token' do
13
+ body = parse_request_body
14
+ grant_type = body[:grant_type]
15
+ subject_token = body[:subject_token]
16
+
17
+ unless grant_type == 'urn:ietf:params:oauth:grant-type:token-exchange'
18
+ halt 400, json_error('unsupported_grant_type', 'expected urn:ietf:params:oauth:grant-type:token-exchange',
19
+ status_code: 400)
20
+ end
21
+
22
+ halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400) unless subject_token
23
+
24
+ unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
25
+ halt 501, json_error('jwks_validation_not_available', 'legion-crypt JWKS support not loaded',
26
+ status_code: 501)
27
+ end
28
+
29
+ rbac_settings = (Legion::Settings[:rbac].is_a?(Hash) && Legion::Settings[:rbac][:entra]) || {}
30
+ tenant_id = rbac_settings[:tenant_id]
31
+ halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500) unless tenant_id
32
+
33
+ jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys"
34
+ issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0"
35
+
36
+ begin
37
+ entra_claims = Legion::Crypt::JWT.verify_with_jwks(
38
+ subject_token, jwks_url: jwks_url, issuers: [issuer]
39
+ )
40
+ rescue Legion::Crypt::JWT::ExpiredTokenError
41
+ halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
42
+ rescue Legion::Crypt::JWT::InvalidTokenError => e
43
+ halt 401, json_error('invalid_token', e.message, status_code: 401)
44
+ rescue Legion::Crypt::JWT::Error => e
45
+ halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
46
+ end
47
+
48
+ unless defined?(Legion::Rbac::EntraClaimsMapper)
49
+ halt 501, json_error('claims_mapper_not_available', 'legion-rbac EntraClaimsMapper not loaded',
50
+ status_code: 501)
51
+ end
52
+
53
+ mapped = Legion::Rbac::EntraClaimsMapper.map_claims(
54
+ entra_claims,
55
+ role_map: rbac_settings[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP,
56
+ group_map: rbac_settings[:group_map] || {},
57
+ default_role: rbac_settings[:default_role] || 'worker'
58
+ )
59
+
60
+ ttl = 28_800
61
+ token = Legion::API::Token.issue_human_token(
62
+ msid: mapped[:sub], name: mapped[:name],
63
+ roles: mapped[:roles], ttl: ttl
64
+ )
65
+
66
+ json_response({
67
+ access_token: token,
68
+ token_type: 'Bearer',
69
+ expires_in: ttl,
70
+ roles: mapped[:roles],
71
+ team: mapped[:team]
72
+ })
73
+ end
74
+ end
75
+
76
+ class << self
77
+ private :register_token_exchange
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module AuthWorker
7
+ def self.registered(app)
8
+ register_worker_token_exchange(app)
9
+ end
10
+
11
+ def self.register_worker_token_exchange(app) # rubocop:disable Metrics/MethodLength
12
+ app.post '/api/auth/worker-token' do
13
+ body = parse_request_body
14
+ grant_type = body[:grant_type]
15
+ entra_token = body[:entra_token]
16
+
17
+ unless grant_type == 'client_credentials'
18
+ halt 400, json_error('unsupported_grant_type', 'grant_type must be client_credentials',
19
+ status_code: 400)
20
+ end
21
+
22
+ halt 400, json_error('missing_entra_token', 'entra_token is required', status_code: 400) unless entra_token
23
+
24
+ unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
25
+ halt 501, json_error('jwks_validation_not_available',
26
+ 'JWKS validation is not available', status_code: 501)
27
+ end
28
+
29
+ entra_settings = Routes::AuthWorker.resolve_entra_settings
30
+ tenant_id = entra_settings[:tenant_id]
31
+ unless tenant_id
32
+ halt 500, json_error('entra_tenant_not_configured',
33
+ 'Entra tenant_id is not configured', status_code: 500)
34
+ end
35
+
36
+ jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys"
37
+ issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0"
38
+
39
+ begin
40
+ claims = Legion::Crypt::JWT.verify_with_jwks(
41
+ entra_token, jwks_url: jwks_url, issuers: [issuer]
42
+ )
43
+ rescue Legion::Crypt::JWT::ExpiredTokenError
44
+ halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
45
+ rescue Legion::Crypt::JWT::InvalidTokenError => e
46
+ halt 401, json_error('invalid_token', e.message, status_code: 401)
47
+ rescue Legion::Crypt::JWT::Error => e
48
+ halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
49
+ end
50
+
51
+ app_id = claims[:appid] || claims[:azp] || claims['appid'] || claims['azp']
52
+ halt 401, json_error('invalid_token', 'missing appid claim', status_code: 401) unless app_id
53
+
54
+ halt 503, json_error('data_unavailable', 'legion-data not connected', status_code: 503) unless defined?(Legion::Data::Model::DigitalWorker)
55
+
56
+ worker = Legion::Data::Model::DigitalWorker.first(entra_app_id: app_id)
57
+ unless worker
58
+ halt 404, json_error('worker_not_found',
59
+ "no worker registered for entra_app_id #{app_id}", status_code: 404)
60
+ end
61
+
62
+ unless worker.lifecycle_state == 'active'
63
+ halt 403, json_error('worker_not_active',
64
+ "worker is in #{worker.lifecycle_state} state", status_code: 403)
65
+ end
66
+
67
+ ttl = 3600
68
+ token = Legion::API::Token.issue_worker_token(
69
+ worker_id: worker.worker_id, owner_msid: worker.owner_msid, ttl: ttl
70
+ )
71
+
72
+ json_response({
73
+ access_token: token,
74
+ token_type: 'Bearer',
75
+ expires_in: ttl,
76
+ worker_id: worker.worker_id,
77
+ scope: 'worker'
78
+ })
79
+ end
80
+ end
81
+
82
+ def self.resolve_entra_settings
83
+ return {} unless defined?(Legion::Settings)
84
+
85
+ identity = Legion::Settings[:identity]
86
+ entra = identity.is_a?(Hash) ? identity[:entra] : nil
87
+ return entra if entra.is_a?(Hash)
88
+
89
+ rbac = Legion::Settings[:rbac]
90
+ entra = rbac.is_a?(Hash) ? rbac[:entra] : nil
91
+ return entra if entra.is_a?(Hash)
92
+
93
+ {}
94
+ rescue StandardError
95
+ {}
96
+ end
97
+
98
+ class << self
99
+ private :register_worker_token_exchange
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -12,6 +12,28 @@ module Legion
12
12
  json_response({ started: false }, status_code: 503)
13
13
  end
14
14
  end
15
+
16
+ app.post '/api/channels/teams/webhook' do
17
+ body = request.body.read
18
+ activity = Legion::JSON.load(body)
19
+
20
+ adapter = Routes::Gaia.teams_adapter
21
+ halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503) unless adapter
22
+
23
+ input_frame = adapter.translate_inbound(activity)
24
+ Legion::Gaia.sensory_buffer&.push(input_frame) if defined?(Legion::Gaia)
25
+
26
+ json_response({ status: 'accepted', frame_id: input_frame&.id })
27
+ end
28
+ end
29
+
30
+ def self.teams_adapter
31
+ return nil unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:channel_registry)
32
+ return nil unless Legion::Gaia.channel_registry
33
+
34
+ Legion::Gaia.channel_registry.adapter_for(:teams)
35
+ rescue StandardError
36
+ nil
15
37
  end
16
38
  end
17
39
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Middleware
6
6
  class Auth
7
- SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze
7
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token].freeze
8
8
  AUTH_HEADER = 'HTTP_AUTHORIZATION'
9
9
  BEARER_PATTERN = /\ABearer\s+(.+)\z/i
10
10
  API_KEY_HEADER = 'HTTP_X_API_KEY'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Webhooks
7
+ def self.registered(app)
8
+ app.get '/api/webhooks' do
9
+ json_response(Legion::Webhooks.list)
10
+ end
11
+
12
+ app.post '/api/webhooks' do
13
+ body = parse_request_body
14
+ result = Legion::Webhooks.register(
15
+ url: body[:url], secret: body[:secret],
16
+ event_types: body[:event_types] || ['*'],
17
+ max_retries: body[:max_retries] || 5
18
+ )
19
+ json_response(result, status_code: 201)
20
+ end
21
+
22
+ app.delete '/api/webhooks/:id' do
23
+ json_response(Legion::Webhooks.unregister(id: params[:id].to_i))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/legion/api.rb CHANGED
@@ -26,6 +26,8 @@ require_relative 'api/gaia'
26
26
  require_relative 'api/oauth'
27
27
  require_relative 'api/openapi'
28
28
  require_relative 'api/rbac'
29
+ require_relative 'api/auth'
30
+ require_relative 'api/auth_worker'
29
31
  require_relative 'api/audit'
30
32
  require_relative 'api/metrics'
31
33
 
@@ -96,6 +98,8 @@ module Legion
96
98
  register Routes::Gaia
97
99
  register Routes::OAuth
98
100
  register Routes::Rbac
101
+ register Routes::Auth
102
+ register Routes::AuthWorker
99
103
  register Routes::Audit
100
104
  register Routes::Metrics
101
105
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Legion
6
+ module Audit
7
+ module HashChain
8
+ ALGORITHM = 'SHA256'
9
+ GENESIS_HASH = ('0' * 64).freeze
10
+ CANONICAL_FIELDS = %i[principal_id action resource source status detail created_at previous_hash].freeze
11
+
12
+ module_function
13
+
14
+ def compute_hash(record)
15
+ payload = canonical_payload(record)
16
+ OpenSSL::Digest.new(ALGORITHM).hexdigest(payload)
17
+ end
18
+
19
+ def canonical_payload(record)
20
+ CANONICAL_FIELDS.map { |f| "#{f}:#{record[f]}" }.join('|')
21
+ end
22
+
23
+ def verify_chain(records)
24
+ broken = []
25
+ records.each_cons(2) do |prev, curr|
26
+ broken << { id: curr[:id], expected: prev[:record_hash], got: curr[:previous_hash] } unless curr[:previous_hash] == prev[:record_hash]
27
+ end
28
+ { valid: broken.empty?, broken_links: broken, records_checked: records.size }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Audit
5
+ module SiemExport
6
+ module_function
7
+
8
+ def export_batch(records)
9
+ records.map do |r|
10
+ {
11
+ timestamp: r[:created_at],
12
+ source: 'legion',
13
+ event_type: r[:event_type] || 'audit',
14
+ principal: r[:principal_id],
15
+ action: r[:action],
16
+ resource: r[:resource],
17
+ status: r[:status],
18
+ detail: r[:detail],
19
+ integrity: {
20
+ record_hash: r[:record_hash],
21
+ previous_hash: r[:previous_hash],
22
+ algorithm: 'SHA256'
23
+ }
24
+ }
25
+ end
26
+ end
27
+
28
+ def to_ndjson(records)
29
+ export_batch(records).map { |r| Legion::JSON.dump(r) }.join("\n")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Legion
7
+ module Catalog
8
+ class << self
9
+ def register_tools(catalog_url:, api_key:)
10
+ tools = collect_mcp_tools
11
+ post_json("#{catalog_url}/api/tools", { tools: tools }, api_key)
12
+ end
13
+
14
+ def register_workers(catalog_url:, api_key:, workers:)
15
+ entries = workers.map do |w|
16
+ { id: w[:worker_id], status: w[:status], capabilities: w[:capabilities] || [] }
17
+ end
18
+ post_json("#{catalog_url}/api/workers", { workers: entries }, api_key)
19
+ end
20
+
21
+ def collect_mcp_tools
22
+ return [] unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:tools)
23
+
24
+ Legion::MCP.tools.map { |t| { name: t[:name], description: t[:description] } }
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ def post_json(url, body, api_key)
32
+ uri = URI(url)
33
+ req = Net::HTTP::Post.new(uri)
34
+ req['Authorization'] = "Bearer #{api_key}"
35
+ req['Content-Type'] = 'application/json'
36
+ req.body = Legion::JSON.dump(body)
37
+
38
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
39
+ http.request(req)
40
+ end
41
+ { status: response.code.to_i, body: response.body }
42
+ rescue StandardError => e
43
+ { error: e.message }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'notification_queue'
4
+
3
5
  module Legion
4
6
  module Chat
5
7
  class NotificationBridge
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Legion
6
+ module Chat
7
+ module Skills
8
+ SKILL_DIRS = ['.legion/skills', '~/.legionio/skills'].freeze
9
+
10
+ class << self
11
+ def discover
12
+ SKILL_DIRS.flat_map do |dir|
13
+ expanded = File.expand_path(dir)
14
+ next [] unless Dir.exist?(expanded)
15
+
16
+ Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
17
+ end
18
+ end
19
+
20
+ def find(name)
21
+ discover.find { |s| s[:name] == name.to_s }
22
+ end
23
+
24
+ def parse(path)
25
+ content = File.read(path)
26
+ return nil unless content.start_with?('---')
27
+
28
+ parts = content.split(/^---\s*$/, 3)
29
+ return nil if parts.size < 3
30
+
31
+ frontmatter = YAML.safe_load(parts[1], permitted_classes: [Symbol])
32
+ body = parts[2]&.strip
33
+
34
+ {
35
+ name: frontmatter['name'] || File.basename(path, '.md'),
36
+ description: frontmatter['description'] || '',
37
+ model: frontmatter['model'],
38
+ tools: Array(frontmatter['tools']),
39
+ prompt: body,
40
+ path: path
41
+ }
42
+ rescue StandardError => e
43
+ Legion::Logging.warn "Skill parse error #{path}: #{e.message}" if defined?(Legion::Logging)
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Chat
6
+ class ProgressBar
7
+ attr_reader :total, :current
8
+
9
+ def initialize(total:, label: '', width: 40, output: $stdout)
10
+ @total = [total, 1].max
11
+ @current = 0
12
+ @label = label
13
+ @width = width
14
+ @output = output
15
+ @start_time = Time.now
16
+ end
17
+
18
+ def advance(amount = 1)
19
+ @current = [@current + amount, @total].min
20
+ render
21
+ self
22
+ end
23
+
24
+ def finish
25
+ @current = @total
26
+ render
27
+ @output.puts
28
+ self
29
+ end
30
+
31
+ def percentage
32
+ (@current.to_f / @total * 100).round(1)
33
+ end
34
+
35
+ def elapsed
36
+ Time.now - @start_time
37
+ end
38
+
39
+ def eta
40
+ return 0 if @current.zero? || @current >= @total
41
+
42
+ (elapsed / @current * (@total - @current)).round
43
+ end
44
+
45
+ private
46
+
47
+ def render
48
+ filled = (@width * @current.to_f / @total).round
49
+ bar = ('#' * filled) + ('-' * [(@width - filled), 0].max)
50
+ @output.print "\r#{@label} [#{bar}] #{percentage}% (#{@current}/#{@total}) ETA: #{eta}s "
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end