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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +104 -0
- data/CLAUDE.md +8 -4
- data/lib/legion/api/auth.rb +82 -0
- data/lib/legion/api/auth_worker.rb +104 -0
- data/lib/legion/api/gaia.rb +22 -0
- data/lib/legion/api/middleware/auth.rb +1 -1
- data/lib/legion/api/webhooks.rb +29 -0
- data/lib/legion/api.rb +4 -0
- data/lib/legion/audit/hash_chain.rb +32 -0
- data/lib/legion/audit/siem_export.rb +33 -0
- data/lib/legion/catalog.rb +47 -0
- data/lib/legion/chat/notification_bridge.rb +2 -0
- data/lib/legion/chat/skills.rb +49 -0
- data/lib/legion/cli/chat/progress_bar.rb +55 -0
- data/lib/legion/cli/chat/team.rb +43 -0
- data/lib/legion/cli/chat_command.rb +19 -1
- data/lib/legion/cli/cost/data_client.rb +53 -0
- data/lib/legion/cli/cost_command.rb +76 -0
- data/lib/legion/cli/lex_templates.rb +64 -0
- data/lib/legion/cli/marketplace_command.rb +79 -0
- data/lib/legion/cli/mcp_command.rb +12 -1
- data/lib/legion/cli/notebook_command.rb +69 -0
- data/lib/legion/cli/skill_command.rb +86 -0
- data/lib/legion/cli.rb +16 -0
- data/lib/legion/context.rb +53 -0
- data/lib/legion/docs/site_generator.rb +48 -0
- data/lib/legion/guardrails.rb +50 -0
- data/lib/legion/isolation.rb +49 -0
- data/lib/legion/mcp/auth.rb +50 -0
- data/lib/legion/mcp/server.rb +8 -2
- data/lib/legion/mcp/tool_governance.rb +77 -0
- data/lib/legion/mcp.rb +9 -0
- data/lib/legion/registry/security_scanner.rb +48 -0
- data/lib/legion/registry.rb +72 -0
- data/lib/legion/sandbox.rb +65 -0
- data/lib/legion/version.rb +1 -1
- data/lib/legion/webhooks.rb +124 -0
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16599e1c095434bfb65c5a5aa763cb821324e192f8e367cad1aff64fadec7e10
|
|
4
|
+
data.tar.gz: 2747877e468f15212ed3bbd19eaa287323d159a676a01e80e6b1ca11e0abed9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e522c7f831673baa38cf887b1493e78e7133d930f3e857b82fefddbeeffbcc88877e4d741330a222e744170bd0e5876ec08deca5c8a00d8dc6cc520fbd0f54dc
|
|
7
|
+
data.tar.gz: 802b45c5a8d13d79f0c52ac872080a6fde0e5b64bc7ee0f46a21fbdb5be56c23ddad2f6b242b535b9a862e13b446735cb57c44ac50a9a0b021a728c620defe20
|
data/.rubocop.yml
CHANGED
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.
|
|
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/
|
|
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 #
|
|
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
|
data/lib/legion/api/gaia.rb
CHANGED
|
@@ -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
|
|
@@ -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
|