Permissions
Where EKOS enforces who may do what and what each reader may see, with the exact code lines and the tests that cover them.
Permissions are enforced in two places: one role check in the web console (who may do what) and one read filter in the Rust ledger (what someone may see of a project). The MCP gateway, the chat and the web command runner reuse those two checks instead of adding their own. Every reference below links to the exact line at commit 3df6fd0, and every rule names the tests that fail if it breaks.
The numbers match the sections below. Only the ekos command run directly skips them.
1. Who may do what: the console's role check
Every role comparison in the console happens in one function, authorize(). Each route declares which dependency calls it, and a test walks every route to prove none is missing.
| Rule | Where it is enforced |
|---|---|
| Four roles map to actions: viewer → read; reviewer → read, review; operator → read, operate; admin → all four | ROLE_ACTIONS, authz.py:25 |
| The only place a role is compared | authorize(), authz.py:161 |
| An API token (an agent's credential) may only read and operate, whatever role it was granted, so review and administration stay with people | TOKEN_ACTIONS, authz.py:34 |
| Platform owners hold every role | roles_in(), authz.py:84 |
Routes reach the check through allow(action), allow_tenant, allow_platform or authenticated |
authz.py:300, authz.py:331, authz.py:348, authz.py:264 |
| A refusal is 403 and is written to the audit log | require(), authz.py:223 |
| A project you have no role in answers 404, not 403, so its existence is not revealed | workspace_for(), authz.py:277 |
| A request becomes a principal (user, token, local owner) | auth.py:130, auth.py:192 |
Local mode accepts only a loopback client address and a loopback Host header |
LoopbackOnly, main.py:115 |
Tests that fail if this breaks:
- test_every_route_declares_an_action: walks every registered route; a route with no check fails it.
- test_public_routes_are_exactly_the_listed_ones: a route that becomes public by accident fails it.
- test_each_role_grants_exactly_its_actions and test_a_token_never_reviews_or_administers_even_with_a_forced_binding.
- test_a_binding_in_another_tenant_does_not_follow_a_token.
- test_a_foreign_host_is_refused and test_a_remote_client_is_refused.
2. What someone may see: the read scope in the ledger
Limits inside a project (to some sources, paths or objects) are enforced in the Rust ledger, not in the console. ScopedStore wraps the knowledge store, and the query runtime, the EKL query language, graph export and every command read through that store interface, so a new reader cannot bypass the filter.
| Rule | Where it is enforced |
|---|---|
| The store wrapper that filters every read | ScopedStore, scope.rs:326; its trait implementation at scope.rs:482 |
| The decision for one object; a deny rule always wins | ReadScope::visible, scope.rs:239 |
| An object of unknown source or path counts as inside a denied area, and outside an allowed one | denies, scope.rs:211; allows, scope.rs:224 |
| The console turns a person's role bindings into a scope | scope_for(), authz.py:124 |
| Every MCP call a console route makes carries that scope; this is the only way a route gets an MCP client | mcp_for_workspace, deps.py:26 |
| The Rust MCP server reads the scope per call; a scope that does not parse fails the call instead of being ignored | mcp.rs:1543 |
| Under a restricting scope only the scoped tools are served; every other tool is refused | SCOPED_TOOLS, mcp.rs:104; refusal at mcp.rs:1562 |
Tests that fail if this breaks:
- Unit tests in
scope.rs: deny_wins_over_allow_whatever_the_order, a_path_deny_hides_what_has_no_path, objects_are_filtered_on_every_object_read, a_relationship_needs_both_ends, evidence_is_readable_only_through_something_visible, search_returns_only_visible_hits. - every_store_method_has_a_decision_here: reads the store trait's source and fails when a new method has no filter decision.
- The leak suite, run against a real
ekos mcp serve: nothing_hidden_by_a_scope_comes_back_from_any_tool, every_other_tool_refuses_a_restricted_reader, a_scope_that_does_not_parse_fails_the_call, the_process_scope_and_the_call_scope_both_apply. It was checked by mutation: switching off a filter inScopedStoremakes it fail. - every_tool_has_a_read_scope_decision: a new MCP tool in neither the allowed nor the refused list fails it.
3. Paths that reuse those two checks
The MCP gateway for agents, the chat and the web command runner each call the role check from section 1 and pass the scope from section 2. None of them decides permissions on its own.
| Path | What it enforces | Code | Tests |
|---|---|---|---|
| MCP gateway for agents | Role check plus the scoped-tool list per tool call; the forwarded call is rebuilt from the tool name and arguments only, so a client cannot send its own scope | _Caller.may, mcp_gateway.py:74; action_for, mcp_policy.py:36; mcp_gateway.py:115 | test_a_viewer_token_sees_and_calls_only_read_tools, test_an_operator_token_may_write_a_note_and_still_cannot_review, test_a_scope_sent_by_the_client_is_dropped, test_the_scoped_tool_list_matches_the_server |
| Chat | The model is offered only tools that need read and that the gateway's check allows, through the scoped client; a paid model needs the tenant's switch |
chat.py:167; chat.py:141 | test_the_chat_never_names_a_review_tool, test_a_restricted_reader_chats_within_their_scope, test_a_paid_model_needs_the_tenant_switch |
| CLI commands from the web | Each command's class maps to an action; admin and external commands need a typed confirmation; paths must stay inside the project; the reviewer is always the signed-in person | ACTIONS, catalogue.py:26; CONFIRMED, :33; :85; build_argv, :89 | test_rights_follow_the_class, test_a_token_cannot_review, test_a_review_names_the_person_not_the_form, test_a_path_must_stay_inside_the_project |
| Review decisions stay human-only | No MCP code may reach a human-only decision: semantics review, conflict resolution, migration approval, session lifecycle | Enforced by tests that read commands/mcp.rs |
no_mcp_code_can_reach_the_review_lifecycle, no_mcp_code_can_reach_the_resolve_path, no_mcp_code_can_reach_the_migration_lifecycle, no_mcp_code_can_reach_the_lifecycle_module |
Limits
These checks cover the web console and its MCP gateway; four things fall outside them.
- The
ekoscommand checks no permissions. Whoever can run it against a project's files sees everything. The protection there is the operating system's file permissions on the EKOS home. - An MCP tool unknown to the console's policy is treated as a read (mcp_policy.py:36). A restricted reader still cannot reach it, because it is not in the scoped list. A new tool that writes must be added to that file, or viewers could call it; no test covers that case.
- Derived objects carry no source (rollups, business-semantics items). A source-restricted reader therefore does not see them: safe, but those readers get less complete answers.
- No outside audit. Real OIDC sign-in, roles, two tenants and a real MCP client run end to end against the Dex identity provider in
deploy/e2e/run.sh, but all of this was written and tested by the project itself.
Check it yourself
Each group of tests runs on its own, from a clone of the repository:
# Console role checks, gateway, chat, web commands (Python; EKOS_BIN = a built ekos binary)
cd web/api && EKOS_BIN=$(realpath ../../ekos/target/release/ekos) uv run pytest tests/test_authz.py tests/test_authz_routes.py tests/test_mcp_gateway.py tests/test_chat.py tests/test_cli_route.py tests/test_local_mode.py
# Read scope: the ledger filter and the leak suite against a real MCP server (Rust)
cd ekos && cargo test -p ekos-ledger scope && cargo test -p ekos --test read_scope
# Human-only decisions and the per-tool scope classification
cd ekos && cargo test -p ekos --lib -- no_mcp_code_can_reach every_tool_has_a_read_scope_decision