codecortex 0.4.0__tar.gz → 0.5.0__tar.gz

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.

Potentially problematic release.


This version of codecortex might be problematic. Click here for more details.

Files changed (63) hide show
  1. {codecortex-0.4.0/src/codecortex.egg-info → codecortex-0.5.0}/PKG-INFO +5 -3
  2. {codecortex-0.4.0 → codecortex-0.5.0}/README.md +4 -2
  3. {codecortex-0.4.0 → codecortex-0.5.0/src/codecortex.egg-info}/PKG-INFO +5 -3
  4. {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/SOURCES.txt +2 -0
  5. codecortex-0.5.0/src/codeintel/__init__.py +1 -0
  6. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/__main__.py +7 -0
  7. codecortex-0.5.0/src/codeintel/auth.py +120 -0
  8. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/gateway.py +22 -9
  9. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/http_server.py +46 -26
  10. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/server.py +18 -1
  11. codecortex-0.5.0/tests/test_rbac.py +169 -0
  12. codecortex-0.4.0/src/codeintel/__init__.py +0 -1
  13. {codecortex-0.4.0 → codecortex-0.5.0}/LICENSE +0 -0
  14. {codecortex-0.4.0 → codecortex-0.5.0}/pyproject.toml +0 -0
  15. {codecortex-0.4.0 → codecortex-0.5.0}/setup.cfg +0 -0
  16. {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  17. {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  18. {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/requires.txt +0 -0
  19. {codecortex-0.4.0 → codecortex-0.5.0}/src/codecortex.egg-info/top_level.txt +0 -0
  20. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/cache.py +0 -0
  21. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/config.py +0 -0
  22. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/doctor.py +0 -0
  23. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/indexer.py +0 -0
  24. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/injector.py +0 -0
  25. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/installer.py +0 -0
  26. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/logconfig.py +0 -0
  27. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/mapper.py +0 -0
  28. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/metrics.py +0 -0
  29. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/onboarding.py +0 -0
  30. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/policy.py +0 -0
  31. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/provider.py +0 -0
  32. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/__init__.py +0 -0
  33. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/graph.py +0 -0
  34. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/lsp.py +0 -0
  35. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/none.py +0 -0
  36. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/providers/semantic.py +0 -0
  37. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/reindexer.py +0 -0
  38. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/reset.py +0 -0
  39. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/searcher.py +0 -0
  40. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/semantic_db.py +0 -0
  41. {codecortex-0.4.0 → codecortex-0.5.0}/src/codeintel/term.py +0 -0
  42. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_cache.py +0 -0
  43. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_config.py +0 -0
  44. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_doctor.py +0 -0
  45. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_e2e.py +0 -0
  46. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_enterprise.py +0 -0
  47. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_gateway.py +0 -0
  48. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_provider.py +0 -0
  49. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_real.py +0 -0
  50. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_graph_stdin.py +0 -0
  51. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_hardening.py +0 -0
  52. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_http_auth.py +0 -0
  53. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_http_server.py +0 -0
  54. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_integration.py +0 -0
  55. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_lsp_provider.py +0 -0
  56. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_lsp_real.py +0 -0
  57. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_mapper.py +0 -0
  58. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_never_raise.py +0 -0
  59. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_onboarding.py +0 -0
  60. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_reindexer.py +0 -0
  61. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_reset.py +0 -0
  62. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_semantic_provider.py +0 -0
  63. {codecortex-0.4.0 → codecortex-0.5.0}/tests/test_term.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -189,6 +189,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
189
189
  | `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
190
190
  | `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
191
191
  | `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
192
+ | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
192
193
 
193
194
  Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
194
195
 
@@ -213,6 +214,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
213
214
  | Variable | Effect |
214
215
  |---|---|
215
216
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
217
+ | `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
216
218
  | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
217
219
  | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
218
220
  | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
@@ -286,9 +288,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
286
288
  | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
287
289
  | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
288
290
 
289
- Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
291
+ Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
290
292
 
291
- **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
293
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
292
294
 
293
295
  ```bash
294
296
  docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
@@ -156,6 +156,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
156
156
  | `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
157
157
  | `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
158
158
  | `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
159
+ | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
159
160
 
160
161
  Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
161
162
 
@@ -180,6 +181,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
180
181
  | Variable | Effect |
181
182
  |---|---|
182
183
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
184
+ | `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
183
185
  | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
184
186
  | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
185
187
  | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
@@ -253,9 +255,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
253
255
  | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
254
256
  | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
255
257
 
256
- Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
258
+ Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
257
259
 
258
- **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
260
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
259
261
 
260
262
  ```bash
261
263
  docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -189,6 +189,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
189
189
  | `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
190
190
  | `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
191
191
  | `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
192
+ | `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
192
193
 
193
194
  Human-facing commands (`doctor`, `status`, `query`, `setup`, `reset`) honor `--no-color` / `NO_COLOR` and `--ascii`, and auto-degrade to plain text when piped.
194
195
 
@@ -213,6 +214,7 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
213
214
  | Variable | Effect |
214
215
  |---|---|
215
216
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
217
+ | `CODEINTEL_AUTH_CONFIG` | Path to an RBAC token→role config (default `~/.codeintel/auth.toml`) — per-token roles + op scopes |
216
218
  | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
217
219
  | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
218
220
  | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
@@ -286,9 +288,9 @@ Running codeintel as a shared service? It ships with what ops teams expect:
286
288
  | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
287
289
  | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
288
290
 
289
- Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
291
+ Plus **bearer-token auth** — or **RBAC** (per-token roles + op scopes via `auth.toml`; a disallowed op returns `403`, and the role is server-authoritative so a client can't escalate) — **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
290
292
 
291
- **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
293
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, **RBAC + SSO-via-auth-proxy**, a Prometheus scrape config, and a security checklist.
292
294
 
293
295
  ```bash
294
296
  docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
@@ -9,6 +9,7 @@ src/codecortex.egg-info/requires.txt
9
9
  src/codecortex.egg-info/top_level.txt
10
10
  src/codeintel/__init__.py
11
11
  src/codeintel/__main__.py
12
+ src/codeintel/auth.py
12
13
  src/codeintel/cache.py
13
14
  src/codeintel/config.py
14
15
  src/codeintel/doctor.py
@@ -52,6 +53,7 @@ tests/test_lsp_real.py
52
53
  tests/test_mapper.py
53
54
  tests/test_never_raise.py
54
55
  tests/test_onboarding.py
56
+ tests/test_rbac.py
55
57
  tests/test_reindexer.py
56
58
  tests/test_reset.py
57
59
  tests/test_semantic_provider.py
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -90,6 +90,8 @@ def main() -> None:
90
90
  reset_parser.add_argument("--yes", "-y", action="store_true", help="Skip the confirmation prompt")
91
91
  reset_parser.add_argument("--json", action="store_true", help="Emit the structured JSON report")
92
92
 
93
+ subparsers.add_parser("gen-token", help="Print a secure random bearer token (for serve-http / RBAC auth.toml)")
94
+
93
95
  args = parser.parse_args()
94
96
 
95
97
  from codeintel import term
@@ -355,6 +357,11 @@ def main() -> None:
355
357
 
356
358
  sys.exit(0 if any_ok else 1)
357
359
 
360
+ elif args.command == "gen-token":
361
+ import secrets
362
+ print(secrets.token_urlsafe(32))
363
+ sys.exit(0)
364
+
358
365
  else:
359
366
  parser.print_help()
360
367
  sys.exit(0)
@@ -0,0 +1,120 @@
1
+ """Token→role authentication and RBAC for the HTTP transport.
2
+
3
+ An optional auth config (``~/.codeintel/auth.toml`` or ``$CODEINTEL_AUTH_CONFIG``) maps bearer
4
+ tokens to roles and roles to the operations they may run. The role is derived **server-side from
5
+ the authenticated token** — a client can never escalate by claiming a role in the request body.
6
+
7
+ Example ``auth.toml``::
8
+
9
+ [roles]
10
+ admin = ["*"] # ["*"] (or omitted) = every op
11
+ reader = ["search", "symbol", "overview", "callers", "callees", "impact", "chain", "context"]
12
+ searcher = ["search", "context"]
13
+
14
+ [tokens] # token = role (store this file mode 0600)
15
+ "s3cret-admin-value" = "admin"
16
+ "sha256:<hex-of-a-token>" = "reader" # pre-hashed form, so plaintext need not be at rest
17
+
18
+ SSO: put an OIDC auth proxy (e.g. oauth2-proxy) in front and have it present a per-role bearer
19
+ token to codeintel — codeintel never speaks to the IdP itself (local-first, no per-query network).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import logging
25
+ import os
26
+ import pathlib
27
+ import tomllib # stdlib on Python 3.11+ (the project's minimum)
28
+ from typing import Dict, List, Optional
29
+
30
+ from codeintel.policy import TieringPolicy
31
+
32
+ logger = logging.getLogger("codeintel")
33
+
34
+ _ALL = "*"
35
+ _SHA_PREFIX = "sha256:"
36
+
37
+
38
+ class TokenAuth:
39
+ """Resolves a bearer token to a role and builds the matching op policy. Tokens are held as
40
+ sha256 hashes; a presented token is hashed and looked up in O(1). Immutable; thread-safe."""
41
+
42
+ def __init__(self, token_hash_to_role: Dict[str, str], role_ops: Dict[str, List[str]]) -> None:
43
+ self._tokens = token_hash_to_role
44
+ self._role_ops = role_ops
45
+
46
+ @property
47
+ def enabled(self) -> bool:
48
+ return bool(self._tokens)
49
+
50
+ def role_for(self, presented_token: str) -> Optional[str]:
51
+ """The role for a presented token, or None if it isn't a known token."""
52
+ if not presented_token:
53
+ return None
54
+ h = hashlib.sha256(presented_token.encode("utf-8")).hexdigest()
55
+ return self._tokens.get(h)
56
+
57
+ def build_policy(self) -> TieringPolicy:
58
+ """A role with ops ``["*"]`` (or none) is unrestricted → omit it from the rules, since
59
+ TieringPolicy treats a role absent from its rules as full-access. Every other role maps to
60
+ its explicit op allowlist (an empty list = deny all ops, a fail-safe default)."""
61
+ rules: Dict[str, List[str]] = {}
62
+ for role, ops in self._role_ops.items():
63
+ if _ALL in ops:
64
+ continue # unrestricted
65
+ rules[role] = list(ops)
66
+ return TieringPolicy(enabled=bool(rules), rules=rules)
67
+
68
+
69
+ def _auth_config_path() -> Optional[pathlib.Path]:
70
+ env = os.environ.get("CODEINTEL_AUTH_CONFIG", "").strip()
71
+ if env:
72
+ p = pathlib.Path(env)
73
+ return p if p.is_file() else None
74
+ default = pathlib.Path.home() / ".codeintel" / "auth.toml"
75
+ return default if default.is_file() else None
76
+
77
+
78
+ def load_auth() -> TokenAuth:
79
+ """Load the token→role config if present. Never raises — a missing or malformed file yields an
80
+ empty (disabled) TokenAuth, and the caller falls back to single-token / no-auth."""
81
+ path = _auth_config_path()
82
+ if path is None:
83
+ return TokenAuth({}, {})
84
+ try:
85
+ with path.open("rb") as fh:
86
+ data = tomllib.load(fh)
87
+ except Exception as exc:
88
+ logger.warning("auth: %s could not be parsed (%s) — RBAC is OFF", path, type(exc).__name__)
89
+ return TokenAuth({}, {})
90
+
91
+ raw_roles = data.get("roles") if isinstance(data.get("roles"), dict) else {}
92
+ role_ops: Dict[str, List[str]] = {}
93
+ for role, ops in raw_roles.items():
94
+ if isinstance(ops, list):
95
+ role_ops[str(role)] = [str(o) for o in ops]
96
+ else:
97
+ # Fail CLOSED: a non-list ops value (the missing-brackets typo `reader = "search"`) must
98
+ # never silently become full access — deny all ops for that role instead.
99
+ logger.warning("auth: role %r ops must be a list (got %r) — denying all ops for it", role, ops)
100
+ role_ops[str(role)] = []
101
+
102
+ raw_tokens = data.get("tokens") if isinstance(data.get("tokens"), dict) else {}
103
+ token_hash_to_role: Dict[str, str] = {}
104
+ for tok, role in raw_tokens.items():
105
+ role = str(role)
106
+ # A token mapped to an undefined role fails safe: define it as deny-all (empty op list).
107
+ if role not in role_ops:
108
+ role_ops[role] = []
109
+ tok = str(tok)
110
+ if tok[:len(_SHA_PREFIX)].lower() == _SHA_PREFIX: # case-insensitive `sha256:` prefix
111
+ h = tok[len(_SHA_PREFIX):].strip().lower()
112
+ else:
113
+ h = hashlib.sha256(tok.encode("utf-8")).hexdigest()
114
+ if h:
115
+ token_hash_to_role[h] = role
116
+
117
+ if not token_hash_to_role:
118
+ logger.warning("auth: %s defines no usable [tokens] — RBAC is OFF (no token→role mapping)", path)
119
+
120
+ return TokenAuth(token_hash_to_role, role_ops)
@@ -132,6 +132,14 @@ class Gateway:
132
132
  log_swallowed(f"Gateway._dispatch_single[{engine_str}.{op_str}]", exc)
133
133
  return safe_null_result(op_str, target_str, engine=engine_str, reason="provider-error")
134
134
 
135
+ def allows(self, role: str, op: str) -> bool:
136
+ """Whether *role* may run *op* under the current policy (True when no policy is configured).
137
+ Lets non-query handlers (e.g. doctor) share the same RBAC gate as query()."""
138
+ try:
139
+ return self._policy is None or self._policy.is_allowed(role, op)
140
+ except Exception:
141
+ return True
142
+
135
143
  def query(
136
144
  self,
137
145
  op=None,
@@ -142,16 +150,25 @@ class Gateway:
142
150
  project_root=None,
143
151
  ) -> Result:
144
152
  try:
145
- try:
146
- self._reindexer.maybe_reindex(str(project_root or ""))
147
- except Exception:
148
- pass
149
-
150
153
  op_str = str(op or "")
151
154
  target_str = str(target or "")
152
155
  engine_str = str(engine or "").strip() or "auto"
153
156
  was_auto = engine_str == "auto"
154
157
 
158
+ # Policy check FIRST — a role denied for this op does NO work (no reindex, no dispatch,
159
+ # no cache lookup). Applies to the modern provider path; the legacy list path has none.
160
+ if (
161
+ self._legacy_providers is None
162
+ and self._policy is not None
163
+ and not self._policy.is_allowed(role, op_str)
164
+ ):
165
+ return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
166
+
167
+ try:
168
+ self._reindexer.maybe_reindex(str(project_root or ""))
169
+ except Exception:
170
+ pass
171
+
155
172
  # Legacy list-based path (backward compat with pre-Phase-2 tests)
156
173
  if self._legacy_providers is not None:
157
174
  for p in self._legacy_providers:
@@ -164,10 +181,6 @@ class Gateway:
164
181
  reason = "engine-unavailable" if engine is not None else "no-result"
165
182
  return safe_null_result(op_str, target_str, reason=reason)
166
183
 
167
- # Policy check — before cache lookup
168
- if self._policy is not None and not self._policy.is_allowed(role, op_str):
169
- return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
170
-
171
184
  # Unknown engine — reject immediately
172
185
  if engine_str not in _KNOWN_ENGINES:
173
186
  return safe_null_result(op_str, target_str, reason="unknown-engine")
@@ -81,24 +81,30 @@ class _Handler(BaseHTTPRequestHandler):
81
81
  self.wfile.write(body)
82
82
  self._observe(status)
83
83
 
84
- def _authorized(self) -> bool:
85
- """When the server was started with a token, require ``Authorization: Bearer <token>``
86
- (constant-time compare). No token configured → auth is disabled (the loopback default)."""
87
- token = getattr(self.server, "auth_token", None)
88
- if not token:
89
- return True
84
+ def _bearer(self) -> str:
90
85
  header = self.headers.get("Authorization", "")
91
86
  prefix = "Bearer "
92
- if not header.startswith(prefix):
93
- return False
94
- presented = header[len(prefix):].strip()
95
- # Compare as UTF-8 bytes: hmac.compare_digest raises TypeError on a non-ASCII str, so a
96
- # malformed header (e.g. `Bearer résumé`) would otherwise crash the handler thread. Encoding
97
- # is always safe and keeps the comparison constant-time.
98
- return hmac.compare_digest(presented.encode("utf-8"), token.encode("utf-8"))
87
+ return header[len(prefix):].strip() if header.startswith(prefix) else ""
88
+
89
+ def _resolve_role(self) -> tuple[bool, str]:
90
+ """Authenticate the request and return ``(authorized, role)``. The role is
91
+ **server-authoritative** derived from the token, never from the request body. RBAC (a
92
+ token→role auth config) takes precedence; else a single shared token; else no auth (the
93
+ loopback default). Bytes-compare so a non-ASCII header can't crash ``hmac.compare_digest``."""
94
+ auth = getattr(self.server, "token_auth", None)
95
+ if auth is not None and auth.enabled:
96
+ role = auth.role_for(self._bearer())
97
+ return (role is not None, role or "")
98
+ token = getattr(self.server, "auth_token", None)
99
+ if not token:
100
+ return (True, "") # auth disabled → full access, no role
101
+ presented = self._bearer()
102
+ ok = bool(presented) and hmac.compare_digest(presented.encode("utf-8"), token.encode("utf-8"))
103
+ return (ok, "")
99
104
 
100
105
  def do_POST(self) -> None:
101
- if not self._authorized():
106
+ ok, role = self._resolve_role()
107
+ if not ok:
102
108
  self._send_json(401, {"error": "unauthorized"})
103
109
  return
104
110
  if self.path not in ("/code/query", "/code/doctor"):
@@ -122,11 +128,14 @@ class _Handler(BaseHTTPRequestHandler):
122
128
  if not isinstance(parsed, dict):
123
129
  self._send_json(400, {"error": "bad-request"})
124
130
  return
131
+ parsed["role"] = role # server-authoritative — overrides any client-supplied role (no escalation)
125
132
  if self.path == "/code/doctor":
126
133
  result = code_doctor_handler(parsed)
127
134
  else:
128
135
  result = code_query_handler(parsed)
129
- self._send_json(200, result)
136
+ # An RBAC denial (query OR doctor) comes back as a safe-null with this reason — 403 it.
137
+ status = 403 if result.get("reason") == "op-not-allowed-for-role" else 200
138
+ self._send_json(status, result)
130
139
 
131
140
  def do_GET(self) -> None:
132
141
  parsed = urlparse(self.path)
@@ -147,9 +156,10 @@ class _Handler(BaseHTTPRequestHandler):
147
156
  self._send_json(503, {"status": "not-ready"})
148
157
  return
149
158
 
150
- # Everything else is auth-gated when a token is configured (metrics can reveal usage
151
- # patterns; status reveals engine/index state).
152
- if not self._authorized():
159
+ # Everything else is auth-gated when auth is configured (metrics can reveal usage patterns;
160
+ # status reveals engine/index state). Any valid token/role may read these operational views.
161
+ ok, _ = self._resolve_role()
162
+ if not ok:
153
163
  self._send_json(401, {"error": "unauthorized"})
154
164
  return
155
165
 
@@ -175,7 +185,8 @@ class CodeIntelHTTPServer(ThreadingHTTPServer):
175
185
  # lock-guarded (query cache, reindexer, LSP sessions, graph project cache) and the semantic
176
186
  # engine is thread-confined with WAL, so concurrent requests are safe.
177
187
  daemon_threads = True
178
- auth_token: str | None = None # set by run() when a token is provided
188
+ auth_token: str | None = None # set by run() when a single shared token is provided
189
+ token_auth = None # set by run(): a TokenAuth for RBAC (token->role), if configured
179
190
 
180
191
  def __init__(self, *args, **kwargs) -> None:
181
192
  super().__init__(*args, **kwargs)
@@ -246,27 +257,36 @@ def run(
246
257
  token: str | None = None,
247
258
  ) -> None:
248
259
  configure_logging()
260
+ from codeintel.auth import load_auth
261
+ token_auth = load_auth() # RBAC (token→role), if an auth config exists
249
262
  server_token = (token or "").strip() or None
263
+ authenticated = bool(server_token) or token_auth.enabled
250
264
  if not _is_loopback(host):
251
265
  if not allow_remote:
252
266
  print(f"refusing to bind non-loopback host {host!r} without --allow-remote — this would "
253
267
  f"expose a code-intel endpoint (your indexed repo) to the network", file=sys.stderr)
254
268
  raise SystemExit(2)
255
- if not server_token and not _no_auth_override():
269
+ if not authenticated and not _no_auth_override():
256
270
  # Fail closed: never serve an UNAUTHENTICATED endpoint on the network by accident (a
257
- # containerized `serve-http --host 0.0.0.0` with no token stops here unless overridden).
258
- print(f"refusing to serve on {host}:{port} with NO authentication — set --token or "
259
- f"CODEINTEL_HTTP_TOKEN (recommended), or set CODEINTEL_ALLOW_NO_AUTH=1 to override "
260
- f"for a trusted network", file=sys.stderr)
271
+ # containerized `serve-http --host 0.0.0.0` with no auth stops here unless overridden).
272
+ print(f"refusing to serve on {host}:{port} with NO authentication — set --token / "
273
+ f"CODEINTEL_HTTP_TOKEN, configure an RBAC auth.toml, or set CODEINTEL_ALLOW_NO_AUTH=1 "
274
+ f"to override for a trusted network", file=sys.stderr)
261
275
  raise SystemExit(2)
262
276
 
263
277
  server = CodeIntelHTTPServer((host, port), _Handler)
264
278
  server.auth_token = server_token
265
- if not _is_loopback(host) and not server_token:
279
+ server.token_auth = token_auth
280
+ if not _is_loopback(host) and not authenticated:
266
281
  print(f"WARNING: serving codeintel on {host}:{port} with NO authentication "
267
282
  f"(CODEINTEL_ALLOW_NO_AUTH set) — anyone who can reach this port can read your "
268
283
  f"indexed repo", file=sys.stderr)
269
- auth_note = " (bearer-token auth required)" if server_token else ""
284
+ if token_auth.enabled:
285
+ auth_note = " (RBAC: token→role auth)"
286
+ elif server_token:
287
+ auth_note = " (bearer-token auth required)"
288
+ else:
289
+ auth_note = ""
270
290
  print(f"Listening on http://{host}:{port}{auth_note}")
271
291
 
272
292
  def _graceful(signum, frame) -> None:
@@ -32,7 +32,15 @@ def _build_gateway() -> Gateway:
32
32
  except Exception:
33
33
  pass
34
34
  semantic = SemanticProvider()
35
- policy = TieringPolicy(enabled=False)
35
+ # RBAC: when an auth config defines restricted roles, enforce role→op; otherwise the policy is
36
+ # disabled (full access). The per-request role is authenticated server-side by the HTTP layer;
37
+ # the local MCP agent passes role="" (unrestricted), so RBAC never affects the stdio transport.
38
+ try:
39
+ from codeintel.auth import load_auth
40
+ auth = load_auth()
41
+ policy = auth.build_policy() if auth.enabled else TieringPolicy(enabled=False)
42
+ except Exception:
43
+ policy = TieringPolicy(enabled=False)
36
44
  return Gateway(graph=graph, lsp=lsp, semantic=semantic, policy=policy, reindexer=_REINDEXER)
37
45
 
38
46
 
@@ -148,9 +156,18 @@ def code_doctor_handler(args: dict) -> dict:
148
156
 
149
157
  project_root = str(args.get("project_root", "") or "")
150
158
  deep = bool(args.get("deep", False))
159
+ role = str(args.get("role", "") or "")
151
160
  # Reuse the singleton gateway's providers so the report reflects the LIVE warmed LSP
152
161
  # session state an agent's real queries hit (and the graph project cache).
153
162
  gw = _get_gateway()
163
+ # RBAC: doctor is a privileged op (engine state + a deep LSP boot on an arbitrary path), so
164
+ # it's gated behind the "doctor" scope — a restricted role must list it (or use "*").
165
+ if not gw.allows(role, "doctor"):
166
+ return {
167
+ "ok": True, "project_root": project_root, "deep": deep,
168
+ "summary": {"ready": 0, "total": 3, "healthy": False},
169
+ "engines": {}, "reason": "op-not-allowed-for-role",
170
+ }
154
171
  return _doctor.run_doctor(
155
172
  project_root, deep=deep, graph=gw.graph, lsp=gw.lsp, semantic=gw.semantic
156
173
  )
@@ -0,0 +1,169 @@
1
+ """RBAC (0.5.0): token→role authentication + per-role op scopes, enforced server-side. The role
2
+ is derived from the authenticated token, never from the request body — a client cannot escalate."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import http.client
7
+ import json
8
+ import threading
9
+
10
+ import pytest
11
+
12
+ from codeintel.auth import TokenAuth, load_auth
13
+ from codeintel.http_server import CodeIntelHTTPServer, _Handler
14
+
15
+
16
+ # --------------------------------------------------------------------------- auth config loading
17
+
18
+ def test_load_auth_parses_roles_and_tokens(tmp_path, monkeypatch):
19
+ cfg = tmp_path / "auth.toml"
20
+ cfg.write_text('[roles]\nadmin = ["*"]\nreader = ["search", "context"]\n'
21
+ '[tokens]\n"admin-tok" = "admin"\n"reader-tok" = "reader"\n')
22
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(cfg))
23
+ auth = load_auth()
24
+ assert auth.enabled
25
+ assert auth.role_for("admin-tok") == "admin"
26
+ assert auth.role_for("reader-tok") == "reader"
27
+ assert auth.role_for("unknown") is None
28
+
29
+
30
+ def test_load_auth_missing_file_is_disabled(tmp_path, monkeypatch):
31
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "nope.toml"))
32
+ assert not load_auth().enabled
33
+
34
+
35
+ def test_load_auth_malformed_file_is_disabled_not_raised(tmp_path, monkeypatch):
36
+ (tmp_path / "auth.toml").write_text("this is not === valid toml")
37
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
38
+ assert not load_auth().enabled
39
+
40
+
41
+ def test_sha256_prefixed_token_matches_plaintext(tmp_path, monkeypatch):
42
+ h = hashlib.sha256(b"secret-value").hexdigest()
43
+ (tmp_path / "auth.toml").write_text(f'[roles]\nadmin = ["*"]\n[tokens]\n"sha256:{h}" = "admin"\n')
44
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
45
+ assert load_auth().role_for("secret-value") == "admin" # hashed at rest, matches plaintext
46
+
47
+
48
+ def test_build_policy_restricts_and_omits_wildcard():
49
+ auth = TokenAuth({"h1": "admin", "h2": "reader"}, {"admin": ["*"], "reader": ["search"]})
50
+ pol = auth.build_policy()
51
+ assert pol.is_allowed("admin", "impact") # ["*"] → unrestricted
52
+ assert pol.is_allowed("reader", "search") # in the allowlist
53
+ assert not pol.is_allowed("reader", "impact") # not in the allowlist
54
+
55
+
56
+ # --------------------------------------------------------------------------- HTTP enforcement
57
+
58
+ @pytest.fixture
59
+ def rbac_server(tmp_path, monkeypatch):
60
+ (tmp_path / "auth.toml").write_text(
61
+ '[roles]\nadmin = ["*"]\nreader = ["search", "context"]\n'
62
+ '[tokens]\n"admintok" = "admin"\n"readertok" = "reader"\n'
63
+ )
64
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
65
+ from codeintel import server as srv
66
+ srv._reset_gateway() # rebuild the gateway so its policy reflects THIS auth config
67
+ s = CodeIntelHTTPServer(("127.0.0.1", 0), _Handler)
68
+ s.token_auth = load_auth() # the same config the gateway policy is built from
69
+ threading.Thread(target=s.serve_forever, daemon=True).start()
70
+ yield s.server_address[1]
71
+ s.shutdown()
72
+ srv._reset_gateway() # cleanup: the next test rebuilds without this policy
73
+
74
+
75
+ def _query(port, body, token=None):
76
+ headers = {"Content-Type": "application/json"}
77
+ if token:
78
+ headers["Authorization"] = f"Bearer {token}"
79
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
80
+ c.request("POST", "/code/query", json.dumps(body).encode(), headers)
81
+ r = c.getresponse()
82
+ out = json.loads(r.read())
83
+ c.close()
84
+ return r.status, out
85
+
86
+
87
+ def test_rbac_no_token_is_401(rbac_server):
88
+ assert _query(rbac_server, {"op": "search", "target": "x"})[0] == 401
89
+
90
+
91
+ def test_rbac_invalid_token_is_401(rbac_server):
92
+ assert _query(rbac_server, {"op": "search", "target": "x"}, token="bogus")[0] == 401
93
+
94
+
95
+ def test_rbac_admin_passes_the_policy_for_a_restricted_op(rbac_server):
96
+ status, body = _query(rbac_server, {"op": "impact", "target": "x"}, token="admintok")
97
+ assert status == 200
98
+ assert body.get("reason") != "op-not-allowed-for-role" # admin is allowed (engine may still be null)
99
+
100
+
101
+ def test_rbac_reader_denied_disallowed_op_is_403(rbac_server):
102
+ status, body = _query(rbac_server, {"op": "impact", "target": "x"}, token="readertok")
103
+ assert status == 403
104
+ assert body["reason"] == "op-not-allowed-for-role"
105
+
106
+
107
+ def test_rbac_reader_allowed_op_is_not_denied(rbac_server):
108
+ status, body = _query(rbac_server, {"op": "search", "target": "x"}, token="readertok")
109
+ assert status == 200
110
+ assert body.get("reason") != "op-not-allowed-for-role"
111
+
112
+
113
+ def test_rbac_role_is_server_authoritative_no_body_escalation(rbac_server):
114
+ # A reader token claiming role="admin" in the body must STILL be treated as a reader.
115
+ status, body = _query(rbac_server, {"op": "impact", "target": "x", "role": "admin"}, token="readertok")
116
+ assert status == 403
117
+ assert body["reason"] == "op-not-allowed-for-role"
118
+
119
+
120
+ def test_load_auth_non_list_ops_fails_closed(tmp_path, monkeypatch):
121
+ # `reader = "search"` (missing brackets) must DENY ALL, never silently grant full access.
122
+ (tmp_path / "auth.toml").write_text('[roles]\nadmin = ["*"]\nreader = "search"\n[tokens]\n"t" = "reader"\n')
123
+ monkeypatch.setenv("CODEINTEL_AUTH_CONFIG", str(tmp_path / "auth.toml"))
124
+ pol = load_auth().build_policy()
125
+ assert not pol.is_allowed("reader", "search") # fail-closed, not the "*" fallback
126
+ assert not pol.is_allowed("reader", "impact")
127
+
128
+
129
+ def _doctor(port, token):
130
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
131
+ c.request("POST", "/code/doctor", b'{"project_root":"/tmp"}',
132
+ {"Content-Type": "application/json", "Authorization": f"Bearer {token}"})
133
+ r = c.getresponse()
134
+ body = json.loads(r.read())
135
+ c.close()
136
+ return r.status, body
137
+
138
+
139
+ def test_rbac_reader_denied_doctor_is_403(rbac_server):
140
+ status, body = _doctor(rbac_server, "readertok") # reader has no "doctor" scope
141
+ assert status == 403 and body["reason"] == "op-not-allowed-for-role"
142
+
143
+
144
+ def test_rbac_admin_allowed_doctor(rbac_server):
145
+ status, body = _doctor(rbac_server, "admintok") # admin is ["*"]
146
+ assert status == 200 and body.get("reason") != "op-not-allowed-for-role"
147
+
148
+
149
+ def test_denied_op_does_no_reindex_work(monkeypatch):
150
+ from codeintel.gateway import Gateway
151
+ from codeintel.policy import TieringPolicy
152
+ from codeintel.reindexer import Reindexer
153
+ rx = Reindexer()
154
+ calls = {"n": 0}
155
+ monkeypatch.setattr(rx, "maybe_reindex", lambda root: calls.__setitem__("n", calls["n"] + 1))
156
+ gw = Gateway(graph=None, lsp=None, semantic=None,
157
+ policy=TieringPolicy(enabled=True, rules={"reader": ["search"]}), reindexer=rx)
158
+ denied = gw.query(op="impact", target="x", role="reader", project_root="/tmp/x")
159
+ assert denied["reason"] == "op-not-allowed-for-role"
160
+ assert calls["n"] == 0 # policy denial skips reindex entirely
161
+ gw.query(op="search", target="x", role="reader", project_root="/tmp/x")
162
+ assert calls["n"] == 1 # an allowed op proceeds to reindex
163
+
164
+
165
+ def test_rbac_healthz_still_unauthenticated(rbac_server):
166
+ c = http.client.HTTPConnection("127.0.0.1", rbac_server, timeout=5)
167
+ c.request("GET", "/healthz")
168
+ assert c.getresponse().status == 200 # probes never require a token, even under RBAC
169
+ c.close()
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"
File without changes
File without changes
File without changes
File without changes