codecortex 0.13.1__tar.gz → 0.14.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.
Files changed (98) hide show
  1. {codecortex-0.13.1/src/codecortex.egg-info → codecortex-0.14.0}/PKG-INFO +7 -1
  2. {codecortex-0.13.1 → codecortex-0.14.0}/README.md +6 -0
  3. {codecortex-0.13.1 → codecortex-0.14.0/src/codecortex.egg-info}/PKG-INFO +7 -1
  4. codecortex-0.14.0/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/auth.py +35 -4
  6. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/cache.py +12 -1
  7. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/graph.py +3 -1
  8. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/index.py +14 -1
  9. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/map.py +3 -1
  10. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/gateway.py +37 -12
  11. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/http_server.py +7 -3
  12. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/indexer.py +56 -2
  13. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/injector.py +36 -7
  14. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/installer.py +30 -3
  15. codecortex-0.14.0/src/codeintel/policy.py +93 -0
  16. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/reindexer.py +29 -5
  17. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/reset.py +48 -3
  18. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/server.py +14 -0
  19. codecortex-0.14.0/tests/test_cache.py +109 -0
  20. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_cli_commands.py +24 -3
  21. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_gateway.py +100 -0
  22. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_graph_stdin.py +29 -1
  23. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_installer.py +47 -0
  24. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_mapper.py +56 -0
  25. codecortex-0.14.0/tests/test_rbac.py +438 -0
  26. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_reindexer.py +63 -1
  27. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_reset.py +44 -0
  28. codecortex-0.13.1/src/codeintel/__init__.py +0 -1
  29. codecortex-0.13.1/src/codeintel/policy.py +0 -28
  30. codecortex-0.13.1/tests/test_cache.py +0 -61
  31. codecortex-0.13.1/tests/test_rbac.py +0 -178
  32. {codecortex-0.13.1 → codecortex-0.14.0}/LICENSE +0 -0
  33. {codecortex-0.13.1 → codecortex-0.14.0}/pyproject.toml +0 -0
  34. {codecortex-0.13.1 → codecortex-0.14.0}/setup.cfg +0 -0
  35. {codecortex-0.13.1 → codecortex-0.14.0}/src/codecortex.egg-info/SOURCES.txt +0 -0
  36. {codecortex-0.13.1 → codecortex-0.14.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  37. {codecortex-0.13.1 → codecortex-0.14.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  38. {codecortex-0.13.1 → codecortex-0.14.0}/src/codecortex.egg-info/requires.txt +0 -0
  39. {codecortex-0.13.1 → codecortex-0.14.0}/src/codecortex.egg-info/top_level.txt +0 -0
  40. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/__main__.py +0 -0
  41. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/__init__.py +0 -0
  42. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/_common.py +0 -0
  43. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/doctor.py +0 -0
  44. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/gen_token.py +0 -0
  45. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/install.py +0 -0
  46. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/query.py +0 -0
  47. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/reset.py +0 -0
  48. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/serve.py +0 -0
  49. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/serve_http.py +0 -0
  50. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/setup.py +0 -0
  51. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/commands/status.py +0 -0
  52. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/config.py +0 -0
  53. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/doctor.py +0 -0
  54. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/grapher.py +0 -0
  55. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/logconfig.py +0 -0
  56. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/mapper.py +0 -0
  57. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/metrics.py +0 -0
  58. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/onboarding.py +0 -0
  59. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/provider.py +0 -0
  60. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/providers/__init__.py +0 -0
  61. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/providers/graph.py +0 -0
  62. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/providers/lsp.py +0 -0
  63. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/providers/none.py +0 -0
  64. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/providers/semantic.py +0 -0
  65. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/py.typed +0 -0
  66. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/searcher.py +0 -0
  67. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/semantic_db.py +0 -0
  68. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/term.py +0 -0
  69. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/verify.py +0 -0
  70. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/viewer/__init__.py +0 -0
  71. {codecortex-0.13.1 → codecortex-0.14.0}/src/codeintel/viewer/graph_template.html +0 -0
  72. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_chunking.py +0 -0
  73. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_cli_help.py +0 -0
  74. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_config.py +0 -0
  75. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_doctor.py +0 -0
  76. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_e2e.py +0 -0
  77. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_engine_adoption.py +0 -0
  78. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_enterprise.py +0 -0
  79. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_graph_provider.py +0 -0
  80. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_graph_real.py +0 -0
  81. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_grapher.py +0 -0
  82. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_hardening.py +0 -0
  83. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_http_auth.py +0 -0
  84. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_http_server.py +0 -0
  85. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_integration.py +0 -0
  86. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_lsp_provider.py +0 -0
  87. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_lsp_real.py +0 -0
  88. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_mcp_handshake.py +0 -0
  89. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_mcp_server.py +0 -0
  90. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_model_dimension.py +0 -0
  91. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_never_raise.py +0 -0
  92. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_onboarding.py +0 -0
  93. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_rerank.py +0 -0
  94. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_semantic_provider.py +0 -0
  95. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_term.py +0 -0
  96. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_treesitter.py +0 -0
  97. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_verify.py +0 -0
  98. {codecortex-0.13.1 → codecortex-0.14.0}/tests/test_verify_call.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.13.1
3
+ Version: 0.14.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
@@ -296,6 +296,12 @@ codeintel index /path/to/repo
296
296
  The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
297
297
  rather than a bare reason.
298
298
 
299
+ One more honest caveat. For targets that are **symbol names or free text** (most of them —
300
+ `callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
301
+ cached answer is invalidated only when a background reindex completes, and those are debounced
302
+ (~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
303
+ Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
304
+
299
305
  Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
300
306
 
301
307
  ## Documentation
@@ -259,6 +259,12 @@ codeintel index /path/to/repo
259
259
  The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
260
260
  rather than a bare reason.
261
261
 
262
+ One more honest caveat. For targets that are **symbol names or free text** (most of them —
263
+ `callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
264
+ cached answer is invalidated only when a background reindex completes, and those are debounced
265
+ (~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
266
+ Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
267
+
262
268
  Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
263
269
 
264
270
  ## Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.13.1
3
+ Version: 0.14.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
@@ -296,6 +296,12 @@ codeintel index /path/to/repo
296
296
  The reply names the fix when it can: a missing symbol now returns a `hint` with the exact command
297
297
  rather than a bare reason.
298
298
 
299
+ One more honest caveat. For targets that are **symbol names or free text** (most of them —
300
+ `callers`, `impact`, `hotspots`, `search`), there is no file whose content hash could change, so a
301
+ cached answer is invalidated only when a background reindex completes, and those are debounced
302
+ (~30s). An edit followed immediately by the same query can therefore return the pre-edit answer.
303
+ Targets that are real file paths are content-hashed and refresh as soon as the bytes change.
304
+
299
305
  Pass `--engine auto` (the default) and codeintel chooses the best engine per operation. Pass `--engine both` or `--engine all` to fan out to multiple engines and merge results.
300
306
 
301
307
  ## Documentation
@@ -0,0 +1 @@
1
+ __version__ = "0.14.0"
@@ -38,9 +38,11 @@ class TokenAuth:
38
38
  """Resolves a bearer token to a role and builds the matching op policy. Tokens are held as
39
39
  sha256 hashes; a presented token is hashed and looked up in O(1). Immutable; thread-safe."""
40
40
 
41
- def __init__(self, token_hash_to_role: dict[str, str], role_ops: dict[str, list[str]]) -> None:
41
+ def __init__(self, token_hash_to_role: dict[str, str], role_ops: dict[str, list[str]],
42
+ role_roots: dict[str, list[str]] | None = None) -> None:
42
43
  self._tokens = token_hash_to_role
43
44
  self._role_ops = role_ops
45
+ self._role_roots = role_roots or {}
44
46
 
45
47
  @property
46
48
  def enabled(self) -> bool:
@@ -56,13 +58,19 @@ class TokenAuth:
56
58
  def build_policy(self) -> TieringPolicy:
57
59
  """A role with ops ``["*"]`` (or none) is unrestricted → omit it from the rules, since
58
60
  TieringPolicy treats a role absent from its rules as full-access. Every other role maps to
59
- its explicit op allowlist (an empty list = deny all ops, a fail-safe default)."""
61
+ its explicit op allowlist (an empty list = deny all ops, a fail-safe default).
62
+
63
+ ``enabled`` is True whenever RBAC is configured at all — NOT merely when some role has a
64
+ restricted op list. It used to be ``bool(rules)``, which meant a config whose roles were
65
+ all ``["*"]`` produced a disabled policy, and a disabled policy enforces no root scoping
66
+ either. Op behavior is unchanged (a role absent from rules is still unrestricted); this
67
+ only ensures the root allowlist is actually consulted."""
60
68
  rules: dict[str, list[str]] = {}
61
69
  for role, ops in self._role_ops.items():
62
70
  if _ALL in ops:
63
71
  continue # unrestricted
64
72
  rules[role] = list(ops)
65
- return TieringPolicy(enabled=bool(rules), rules=rules)
73
+ return TieringPolicy(enabled=True, rules=rules, roots=dict(self._role_roots))
66
74
 
67
75
 
68
76
  def _auth_config_path() -> pathlib.Path | None:
@@ -99,6 +107,19 @@ def load_auth() -> TokenAuth:
99
107
  logger.warning("auth: role %r ops must be a list (got %r) — denying all ops for it", role, ops)
100
108
  role_ops[str(role)] = []
101
109
 
110
+ roots_section = data.get("roots")
111
+ raw_roots: dict = roots_section if isinstance(roots_section, dict) else {}
112
+ role_roots: dict[str, list[str]] = {}
113
+ for role, paths in raw_roots.items():
114
+ if isinstance(paths, list):
115
+ role_roots[str(role)] = [str(r) for r in paths]
116
+ else:
117
+ # Same fail-CLOSED rule as ops: a bare string (`reader = "/srv/repo"`) is a typo, and
118
+ # guessing it meant a one-element list would hand out access the operator never wrote.
119
+ logger.warning("auth: role %r roots must be a list (got %r) — denying all paths for it",
120
+ role, paths)
121
+ role_roots[str(role)] = []
122
+
102
123
  tokens_section = data.get("tokens")
103
124
  raw_tokens: dict = tokens_section if isinstance(tokens_section, dict) else {}
104
125
  token_hash_to_role: dict[str, str] = {}
@@ -107,6 +128,7 @@ def load_auth() -> TokenAuth:
107
128
  # A token mapped to an undefined role fails safe: define it as deny-all (empty op list).
108
129
  if role not in role_ops:
109
130
  role_ops[role] = []
131
+ role_roots.setdefault(role, [])
110
132
  tok = str(tok)
111
133
  if tok[:len(_SHA_PREFIX)].lower() == _SHA_PREFIX: # case-insensitive `sha256:` prefix
112
134
  h = tok[len(_SHA_PREFIX):].strip().lower()
@@ -118,4 +140,13 @@ def load_auth() -> TokenAuth:
118
140
  if not token_hash_to_role:
119
141
  logger.warning("auth: %s defines no usable [tokens] — RBAC is OFF (no token→role mapping)", path)
120
142
 
121
- return TokenAuth(token_hash_to_role, role_ops)
143
+ # A configured-but-unscoped RBAC deployment is the exact shape of the hole this closes, so say
144
+ # so at load time rather than letting the operator discover it as a wall of 403s.
145
+ unscoped = sorted(r for r in role_ops if not role_roots.get(r))
146
+ if token_hash_to_role and unscoped:
147
+ logger.warning(
148
+ "auth: roles %s have no [roots] entry and may target NO project — add a [roots] table "
149
+ "(e.g. `%s = [\"/srv/repos/team-a\"]`, or `= [\"*\"]` for unrestricted)",
150
+ ", ".join(unscoped), unscoped[0])
151
+
152
+ return TokenAuth(token_hash_to_role, role_ops, role_roots)
@@ -9,9 +9,20 @@ from codeintel.provider import Result
9
9
 
10
10
 
11
11
  def _compute_hash(target: str, project_root: str) -> str:
12
+ """The cache key's content component: a file target hashes its BYTES, so an edit invalidates.
13
+
14
+ A relative target must be resolved against ``project_root``, not the process cwd. It used to
15
+ use ``os.path.realpath(target)`` alone — and the ops whose target is a path take it
16
+ repo-relative (`overview` passes it straight through as serena's ``relative_path``). The
17
+ server is a long-lived singleton answering for whichever ``project_root`` a caller names, so
18
+ its cwd matches at most one repo and usually none: `inside_root` was false, and every such
19
+ entry silently fell back to hashing the *string*, which never changes when the file does.
20
+ That is exactly the "an edit forces a refresh" guarantee docs/architecture.md advertises."""
12
21
  try:
13
22
  root = os.path.realpath(project_root) if project_root else ""
14
- path = os.path.realpath(target)
23
+ # Relative targets resolve inside the project; absolute ones are already anchored.
24
+ raw = target if os.path.isabs(target) or not root else os.path.join(root, target)
25
+ path = os.path.realpath(raw)
15
26
  inside_root = (root and path.startswith(root + os.sep)) or path == root
16
27
  if inside_root and os.path.isfile(path):
17
28
  with open(path, "rb") as fh:
@@ -6,7 +6,9 @@ from typing import Any
6
6
  from codeintel.commands._common import never_raise, resolve_root
7
7
 
8
8
 
9
- @never_raise("graph failed: {exc}")
9
+ # code=1: this command's job is to WRITE A FILE. Exiting 0 after failing to write it
10
+ # reports success to any `make`/CI step gating on $? while nothing was produced.
11
+ @never_raise("graph failed: {exc}", code=1)
10
12
  def run(args: Any) -> int:
11
13
  from codeintel import grapher
12
14
 
@@ -12,6 +12,13 @@ def run(args: Any) -> int:
12
12
  from codeintel.semantic_db import SemanticDb, default_db_path
13
13
 
14
14
  project_root = resolve_root(args)
15
+ # Validate before doing anything. `codeintel index /typo/path` walked nothing, found nothing,
16
+ # and printed "Nothing new to index" at exit 0 — indistinguishable from a correct incremental
17
+ # run, which is the worst possible response to a mistyped path in a script.
18
+ if not os.path.isdir(project_root):
19
+ print(f"index failed: not a directory: {project_root}")
20
+ return 1
21
+ failed = False
15
22
  # Wrap the whole semantic pass so a setup failure (e.g. an unresolvable home dir →
16
23
  # Path.home() raising) degrades with a message, like every other subcommand, not a traceback.
17
24
  try:
@@ -32,12 +39,18 @@ def run(args: Any) -> int:
32
39
  ).index(project_root)
33
40
  if count > 0:
34
41
  print(f"Indexed {count} chunks")
42
+ elif count < 0:
43
+ # Indexer.index() returns -1 for an unrecoverable failure. `> 0` sent that into
44
+ # the "Nothing new to index" branch, so a total failure read as a clean no-op.
45
+ print("index failed — the indexer could not complete (see the warnings above)")
46
+ failed = True
35
47
  else:
36
48
  print("Nothing new to index")
37
49
  finally:
38
50
  db.close()
39
51
  except Exception as exc:
40
52
  print(f"index failed: {exc}")
53
+ failed = True
41
54
 
42
55
  # best-effort graph reindex
43
56
  import shutil
@@ -60,4 +73,4 @@ def run(args: Any) -> int:
60
73
  except Exception:
61
74
  pass
62
75
 
63
- return 0
76
+ return 1 if failed else 0
@@ -6,7 +6,9 @@ from codeintel.commands._common import never_raise, resolve_root
6
6
 
7
7
 
8
8
  # Never-raise parity with the MCP code.map handler — degrade, don't crash.
9
- @never_raise("map failed: {exc}")
9
+ # code=1: this command's job is to WRITE A FILE. Exiting 0 after failing to write it
10
+ # reports success to any `make`/CI step gating on $? while nothing was produced.
11
+ @never_raise("map failed: {exc}", code=1)
10
12
  def run(args: Any) -> int:
11
13
  from codeintel.injector import Injector
12
14
  from codeintel.mapper import MapGenerator
@@ -191,6 +191,15 @@ class Gateway:
191
191
  except Exception:
192
192
  return True
193
193
 
194
+ def allows_root(self, role: str, project_root: str) -> bool:
195
+ """Whether *role* may target *project_root*. The companion to ``allows`` — an op gate alone
196
+ leaves the TARGET unbounded, which is how `doctor` and `status` could still be pointed at
197
+ any readable directory after `query` had been scoped."""
198
+ try:
199
+ return self._policy is None or self._policy.is_root_allowed(role, project_root)
200
+ except Exception:
201
+ return True
202
+
194
203
  def query(
195
204
  self,
196
205
  op=None,
@@ -206,14 +215,21 @@ class Gateway:
206
215
  engine_str = str(engine or "").strip() or "auto"
207
216
  was_auto = engine_str == "auto"
208
217
 
209
- # Policy check FIRST — a role denied for this op does NO work (no reindex, no dispatch,
210
- # no cache lookup). Applies to the modern provider path; the legacy list path has none.
211
- if (
212
- self._legacy_providers is None
213
- and self._policy is not None
214
- and not self._policy.is_allowed(role, op_str)
215
- ):
216
- return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
218
+ # Policy check FIRST — a role denied here does NO work (no reindex, no dispatch, no
219
+ # cache lookup, and critically no on-demand indexing walk). Applies to the modern
220
+ # provider path; the legacy list path has none.
221
+ if self._legacy_providers is None and self._policy is not None:
222
+ if not self._policy.is_allowed(role, op_str):
223
+ return safe_null_result(op_str, target_str, reason="op-not-allowed-for-role")
224
+ # `project_root` arrives in the request body. Without this check any role able to
225
+ # call `search` could name ANY directory the server process can read, and the
226
+ # semantic provider would walk, index, and return its contents — an op allowlist
227
+ # never sees the target. Denied before maybe_reindex, so a rejected path is not
228
+ # even touched.
229
+ if not self._policy.is_root_allowed(role, str(project_root or "")):
230
+ return safe_null_result(op_str, target_str, reason="root-not-allowed-for-role",
231
+ hint="this token's role is not scoped to that "
232
+ "project_root (see the [roots] table in auth.toml)")
217
233
 
218
234
  try:
219
235
  self._reindexer.maybe_reindex(str(project_root or ""))
@@ -240,6 +256,15 @@ class Gateway:
240
256
  if engine_str == "auto":
241
257
  engine_str = _AUTO_ENGINE.get(op_str, "graph")
242
258
 
259
+ # Cache under what was ASKED, not what auto resolved to. `auto` and an explicit
260
+ # `graph` both resolved to "graph" and so shared one key — but they are different
261
+ # questions: `auto` accepts the overview LSP fallback below, an explicit `graph`
262
+ # does not. One `auto` miss therefore parked an LSP answer under the graph key, and
263
+ # the next explicit `engine=graph` request got it back with `cached: true` and an
264
+ # `engine: "lsp"` field contradicting its own request. Reachable on any cold start,
265
+ # since "graph not indexed yet" is the normal first-query state.
266
+ cache_engine = "auto" if was_auto else engine_str
267
+
243
268
  root_str = project_root or ""
244
269
 
245
270
  # Freshness token — bumps when a background reindex completes, so a cached
@@ -259,7 +284,7 @@ class Gateway:
259
284
  if engine_str in _FANOUT_ENGINES:
260
285
  cached_result = (
261
286
  None if uncacheable
262
- else self._cache.get(op_str, target_str, engine_str, root_str, freshness)
287
+ else self._cache.get(op_str, target_str, cache_engine, root_str, freshness)
263
288
  )
264
289
  if cached_result is not None:
265
290
  return {**cached_result, "cached": True}
@@ -268,13 +293,13 @@ class Gateway:
268
293
  fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
269
294
  result = self._merge(fan_results, op_str, target_str, engine_str)
270
295
  if not uncacheable:
271
- self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
296
+ self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
272
297
  return result
273
298
 
274
299
  # Single-engine dispatch (`uncacheable`, computed above, also guards this path).
275
300
  cached_result = (
276
301
  None if uncacheable
277
- else self._cache.get(op_str, target_str, engine_str, root_str, freshness)
302
+ else self._cache.get(op_str, target_str, cache_engine, root_str, freshness)
278
303
  )
279
304
  if cached_result is not None:
280
305
  return {**cached_result, "cached": True}
@@ -298,7 +323,7 @@ class Gateway:
298
323
  result = lsp_result
299
324
 
300
325
  if not uncacheable:
301
- self._cache.put(op_str, target_str, engine_str, root_str, result, freshness)
326
+ self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
302
327
  return result
303
328
 
304
329
  except Exception as exc:
@@ -168,8 +168,10 @@ class _Handler(BaseHTTPRequestHandler):
168
168
  return
169
169
 
170
170
  # Everything else is auth-gated when auth is configured (metrics can reveal usage patterns;
171
- # status reveals engine/index state). Any valid token/role may read these operational views.
172
- ok, _ = self._resolve_role()
171
+ # status reveals engine/index state). The role is kept, not discarded: /code/status takes a
172
+ # project_root, and answering it for any path regardless of the role's [roots] was the same
173
+ # cross-tenant disclosure that scoping /code/query was meant to close.
174
+ ok, role = self._resolve_role()
173
175
  if not ok:
174
176
  self._send_json(401, {"error": "unauthorized"})
175
177
  return
@@ -185,7 +187,9 @@ class _Handler(BaseHTTPRequestHandler):
185
187
  return
186
188
  if path == "/code/status":
187
189
  project_root = (parse_qs(parsed.query).get("project_root") or [""])[0]
188
- self._send_json(200, code_status_handler({"project_root": project_root}))
190
+ # role is server-authoritative, exactly as on the POST path — never read from input.
191
+ self._send_json(200, code_status_handler(
192
+ {"project_root": project_root, "role": role}))
189
193
  return
190
194
  self._send_json(404, {"error": "not-found"})
191
195
 
@@ -13,6 +13,11 @@ if TYPE_CHECKING:
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
+ # Hard ceiling on the characters embedded for ONE chunk. Line-based splitting cannot bound a
17
+ # minified or generated single-line file, and the embedder's memory use scales with input size.
18
+ # Generous enough that no hand-written function is affected.
19
+ _MAX_CHUNK_CHARS = 200_000
20
+
16
21
  _INDEXED_EXTS = frozenset({
17
22
  ".py", ".md",
18
23
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
@@ -206,7 +211,21 @@ class Indexer:
206
211
  logger.warning("Cleanup pass failed: %s", exc)
207
212
 
208
213
  def _walk_files(self, root: Path):
214
+ """Indexable files inside *root* — and strictly inside it.
215
+
216
+ ``os.walk`` defaults to ``followlinks=False``, which stops recursion into symlinked
217
+ DIRECTORIES but says nothing about symlinked FILES: those still appear in ``filenames``,
218
+ and the later ``open()`` follows them transparently. That made a planted symlink a
219
+ complete bypass of RBAC project scoping — a tenant able to write inside their own allowed
220
+ root could link to any file the server process can read and have it indexed, embedded, and
221
+ returned as a search snippet. Demonstrated before this guard existed.
222
+
223
+ So every candidate is resolved and required to land back under the resolved root."""
209
224
  ignores = set(_SKIP_DIRS) | set(_DEFAULT_IGNORES) | self._load_gitignore(root)
225
+ try:
226
+ real_root = os.path.realpath(root)
227
+ except Exception:
228
+ real_root = str(root)
210
229
  for dirpath, dirnames, filenames in os.walk(root):
211
230
  dirnames[:] = [
212
231
  d for d in dirnames
@@ -215,8 +234,32 @@ class Indexer:
215
234
  for fname in filenames:
216
235
  if fname in ignores:
217
236
  continue
218
- if Path(fname).suffix.lower() in _INDEXED_EXTS:
219
- yield Path(dirpath) / fname
237
+ if Path(fname).suffix.lower() not in _INDEXED_EXTS:
238
+ continue
239
+ candidate = Path(dirpath) / fname
240
+ try:
241
+ real = os.path.realpath(candidate)
242
+ except Exception:
243
+ continue
244
+ if real != real_root and not real.startswith(real_root + os.sep):
245
+ logger.warning("skipping %s — it resolves outside the indexed root", candidate)
246
+ continue
247
+ # A HARDLINK is a second directory entry for the same inode. It is physically
248
+ # inside the root, so its realpath is inside the root and the check above passes
249
+ # — `realpath` cannot see it. That reopened exactly the hole the symlink guard
250
+ # closes: a tenant able to write in their own root could `ln` another tenant's
251
+ # file in and have it indexed. There is no way to ask "does this inode also live
252
+ # outside?", so treat extra links as disqualifying. Measured at 0 occurrences
253
+ # across 3213 source files of a real repo, so the false-positive cost is nil.
254
+ try:
255
+ links = os.stat(real).st_nlink
256
+ except OSError:
257
+ continue
258
+ if links > 1:
259
+ logger.warning("skipping %s — %d hard links, so its content may also live "
260
+ "outside the indexed root", candidate, links)
261
+ continue
262
+ yield candidate
220
263
 
221
264
  # ---- chunk-span computation ------------------------------------------------------------
222
265
  # A file is turned into a list of 0-based, half-open ``(start, end)`` line spans; every
@@ -451,6 +494,17 @@ class Indexer:
451
494
  if not chunk_lines:
452
495
  continue
453
496
  chunk_text = "".join(chunk_lines)
497
+ # Cap chunk BYTES, not just lines. `_maybe_split` splits on line boundaries, so a
498
+ # minified bundle or a generated one-liner is a single unsplittable chunk however
499
+ # large: a 20MB one-line .py peaked at 3.4GB RSS through the embedder, and a 40MB
500
+ # one extrapolates past 8GB. That runs on the reindexer's daemon thread inside the
501
+ # long-lived MCP server, so it can take the agent host down, not just a CLI run.
502
+ # Truncation is the right trade — the head of a chunk carries its identifying
503
+ # content, and an over-long minified line has no retrieval value past that anyway.
504
+ if len(chunk_text) > _MAX_CHUNK_CHARS:
505
+ logger.warning("truncating an oversized chunk in %s (%d chars) to %d",
506
+ rel_path, len(chunk_text), _MAX_CHUNK_CHARS)
507
+ chunk_text = chunk_text[:_MAX_CHUNK_CHARS]
454
508
  if not chunk_text.strip():
455
509
  # EC3.4: never embed empty/whitespace-only chunks (zero vectors pollute results).
456
510
  chunk_count += 1
@@ -49,7 +49,9 @@ def _find_context_file(project_root: str) -> str | None:
49
49
 
50
50
  def _read_file(path: str) -> str | None:
51
51
  try:
52
- with open(path, encoding="utf-8", errors="strict") as f:
52
+ # newline="" keeps the file's own line endings intact. Universal-newline mode silently
53
+ # converted a CRLF file to LF on write, producing a whole-file diff on every --inject.
54
+ with open(path, encoding="utf-8", errors="strict", newline="") as f:
53
55
  return f.read()
54
56
  except Exception as exc:
55
57
  _logger.warning("Injector: could not read %s: %s", path, exc)
@@ -59,10 +61,26 @@ def _read_file(path: str) -> str | None:
59
61
  def _write_file(path: str, content: str) -> None:
60
62
  # Atomic: write a sibling temp file then os.replace it into place, so an interrupted
61
63
  # write can never leave the user's CLAUDE.md/AGENTS.md truncated or half-written.
62
- tmp = path + ".codeintel.tmp"
63
- with open(tmp, "w", encoding="utf-8") as f:
64
+ #
65
+ # os.replace swaps in a NEW inode, so without care it also (a) drops the original's
66
+ # permissions in favour of whatever the umask gives, and (b) replaces a symlinked
67
+ # CLAUDE.md — a dotfile-manager or shared-team-rules setup — with a regular file, orphaning
68
+ # the source. Resolve the link and carry the mode across.
69
+ target = os.path.realpath(path)
70
+ try:
71
+ mode: int | None = os.stat(target).st_mode & 0o7777
72
+ except OSError:
73
+ mode = None
74
+
75
+ tmp = target + ".codeintel.tmp"
76
+ with open(tmp, "w", encoding="utf-8", newline="") as f:
64
77
  f.write(content)
65
- os.replace(tmp, path)
78
+ if mode is not None:
79
+ try:
80
+ os.chmod(tmp, mode)
81
+ except OSError:
82
+ pass
83
+ os.replace(tmp, target)
66
84
 
67
85
 
68
86
  def _update_block(content: str) -> tuple[str, str]:
@@ -73,9 +91,20 @@ def _update_block(content: str) -> tuple[str, str]:
73
91
 
74
92
  if has_start and has_end:
75
93
  start_idx = content.index(_START_MARKER)
76
- end_idx = content.index(_END_MARKER) + len(_END_MARKER)
77
- new_content = content[:start_idx] + block + content[end_idx:]
78
- return (new_content, "updated")
94
+ # The END marker must be searched for AFTER the start. `content.index(_END_MARKER)` finds
95
+ # the FIRST one, which can sit before start_idx a hand-edit, a bad merge, or this
96
+ # function's own append branch below, which happily creates that ordering. The slice
97
+ # `content[:start_idx] + block + content[end_idx:]` then RE-EMITS everything between the
98
+ # stray marker and the block, duplicating the user's own instructions once per run and
99
+ # growing without bound. CLAUDE.md is prompt context, so this silently degrades the agent
100
+ # it is supposed to help.
101
+ end_rel = content.find(_END_MARKER, start_idx)
102
+ if end_rel != -1:
103
+ end_idx = end_rel + len(_END_MARKER)
104
+ return (content[:start_idx] + block + content[end_idx:], "updated")
105
+ # Start with no end after it: the block is truncated. Replace from the start marker to
106
+ # the end of the file rather than splicing around a marker that precedes it.
107
+ return (content[:start_idx] + block, "repaired")
79
108
 
80
109
  # Missing or corrupted (only one marker): append a fresh block
81
110
  separator = "\n\n" if not content.endswith("\n\n") else ""
@@ -34,9 +34,30 @@ def _atomic_write_text(path: pathlib.Path, text: str) -> None:
34
34
  """Write via a sibling temp file + os.replace so an interrupted write can never truncate
35
35
  the user's existing agent config (which holds unrelated settings) to a partial/empty file.
36
36
  The temp lives in the same directory as ``path`` so the replace stays on one filesystem."""
37
- tmp = path.with_name(path.name + ".codeintel.tmp")
37
+ #
38
+ # os.replace installs a NEW inode, which without care also (a) drops the original's
39
+ # permissions in favour of the umask — ~/.claude.json holds OAuth tokens and per-server env
40
+ # secrets and is typically 0600, so this quietly widened it to 0644 — and (b) replaces a
41
+ # symlinked config (chezmoi/stow/yadm) with a regular file, orphaning the real source.
42
+ target = pathlib.Path(os.path.realpath(path))
43
+ try:
44
+ mode: int | None = target.stat().st_mode & 0o7777
45
+ except OSError:
46
+ mode = None
47
+
48
+ tmp = target.with_name(target.name + ".codeintel.tmp")
38
49
  tmp.write_text(text, encoding="utf-8")
39
- os.replace(tmp, path)
50
+ if mode is not None:
51
+ try:
52
+ os.chmod(tmp, mode)
53
+ except OSError:
54
+ pass
55
+ else:
56
+ try: # a config we are creating holds secrets — don't inherit 0644
57
+ os.chmod(tmp, 0o600)
58
+ except OSError:
59
+ pass
60
+ os.replace(tmp, target)
40
61
 
41
62
 
42
63
  # Per-agent registration recipe.
@@ -292,7 +313,13 @@ class Installer:
292
313
  if config_path.exists():
293
314
  data = json.loads(config_path.read_text(encoding="utf-8"))
294
315
  if not isinstance(data, dict):
295
- data = {}
316
+ # Every other malformed-input path here fails safe (invalid JSON → failed, no
317
+ # write). Silently substituting {} made this the one branch that DESTROYED the
318
+ # file: a top-level array or string was replaced wholesale, exit 0, reported
319
+ # "registered". Refuse instead — the user can look at it and decide.
320
+ raise ValueError(
321
+ f"{config_path} does not contain a JSON object (found "
322
+ f"{type(data).__name__}) — refusing to overwrite it; move it aside and retry")
296
323
  else:
297
324
  data = {}
298
325
 
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ _ALL = "*"
6
+
7
+
8
+ def _normalize_root(path: str) -> str:
9
+ """An absolute, symlink-resolved root for containment comparison. Resolving BOTH sides is what
10
+ makes `..` traversal and symlink escapes ineffective: `/srv/a/../../etc` normalizes to `/etc`
11
+ and simply fails the prefix test."""
12
+ try:
13
+ return os.path.realpath(path).rstrip(os.sep) or os.sep
14
+ except Exception:
15
+ return ""
16
+
17
+
18
+ class TieringPolicy:
19
+ """Role access policy — disabled by default (everything allowed).
20
+
21
+ Two independent dimensions, and they are NOT interchangeable:
22
+
23
+ * **ops** (*rules*) — which operations a role may call. A role absent from *rules* is
24
+ unrestricted, which is why an op allowlist alone is fail-open by design.
25
+ * **roots** — which project directories a role may point those operations AT. This one fails
26
+ CLOSED: a role with no roots declared may target nothing.
27
+
28
+ The asymmetry is deliberate. `project_root` arrives in the request body, so without a root
29
+ allowlist any role that can call `search` can direct the server to walk, index, and return the
30
+ contents of any directory the server process can read — the op allowlist never sees it. Scoping
31
+ operations while leaving the target unbounded is not a partial restriction; it is none.
32
+
33
+ Immutable after construction; safe to share across threads.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ enabled: bool = False,
39
+ rules: dict[str, list[str]] | None = None,
40
+ roots: dict[str, list[str]] | None = None,
41
+ ) -> None:
42
+ self._enabled = enabled
43
+ self._rules: dict[str, list[str]] = (
44
+ {role: list(ops) for role, ops in rules.items()} if rules else {}
45
+ )
46
+ self._roots: dict[str, list[str]] = {}
47
+ for role, paths in (roots or {}).items():
48
+ if _ALL in paths:
49
+ self._roots[role] = [_ALL]
50
+ else:
51
+ # Drop blanks BEFORE normalizing. `_normalize_root("")` is the process cwd, not
52
+ # "", so a stray empty string or trailing-comma artifact in an operator's roots
53
+ # list silently added "wherever the server was launched" to the allowlist — the
54
+ # one fail-OPEN case in an otherwise fail-closed model.
55
+ self._roots[role] = [
56
+ r for r in (_normalize_root(str(p)) for p in paths if str(p).strip()) if r
57
+ ]
58
+
59
+ def is_allowed(self, role: str, op: str) -> bool:
60
+ if not self._enabled:
61
+ return True
62
+ allowed_ops = self._rules.get(role)
63
+ if allowed_ops is None:
64
+ return True
65
+ return op in allowed_ops
66
+
67
+ def is_root_allowed(self, role: str, project_root: str) -> bool:
68
+ """May *role* target *project_root*? Fails closed when RBAC is on.
69
+
70
+ Containment is computed on realpaths of both sides, so `..` segments and a symlinked
71
+ project_root cannot walk out of an allowed root. A blank project_root is rejected before
72
+ resolution, because realpath("") is the server's cwd rather than an error.
73
+
74
+ NOTE the boundary this does and does not draw: it bounds which root a caller may NAME. It
75
+ does not bound what the indexer reads once inside — that containment lives in
76
+ ``Indexer._walk_files``, which must skip symlinks escaping the tree, or a tenant who can
77
+ write into their own root defeats this by planting one."""
78
+ if not self._enabled:
79
+ return True
80
+ allowed = self._roots.get(role)
81
+ if not allowed: # undeclared or explicitly empty → nothing permitted
82
+ return False
83
+ if _ALL in allowed:
84
+ return True
85
+ # Reject blank BEFORE resolving. `os.path.realpath("")` returns the process's cwd, so a
86
+ # missing project_root silently became "wherever the server was launched" — and passed
87
+ # whenever that happened to sit under an allowed root. The docstring claimed otherwise.
88
+ if not project_root or not project_root.strip():
89
+ return False
90
+ target = _normalize_root(project_root)
91
+ if not target:
92
+ return False
93
+ return any(target == root or target.startswith(root + os.sep) for root in allowed)