cluxion-agentplugin-supercoder 0.2.7__tar.gz → 0.2.8__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 (67) hide show
  1. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/PKG-INFO +1 -1
  2. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/__init__.py +1 -1
  3. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/pyproject.toml +1 -1
  4. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/scripts/repack_native_wheel.py +1 -5
  5. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/cli.py +1 -0
  6. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/cursor.py +6 -2
  7. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/hash_patch.py +4 -4
  8. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/framework.py +13 -10
  9. cluxion_agentplugin_supercoder-0.2.8/src/cluxion_agentplugin_supercoder/doctor/probes.py +355 -0
  10. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/runner.py +12 -1
  11. cluxion_agentplugin_supercoder-0.2.8/tests/test_cursor.py +72 -0
  12. cluxion_agentplugin_supercoder-0.2.8/tests/test_doctor.py +246 -0
  13. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_hash_patch.py +3 -6
  14. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/uv.lock +1 -1
  15. cluxion_agentplugin_supercoder-0.2.7/src/cluxion_agentplugin_supercoder/doctor/probes.py +0 -187
  16. cluxion_agentplugin_supercoder-0.2.7/tests/test_cursor.py +0 -14
  17. cluxion_agentplugin_supercoder-0.2.7/tests/test_doctor.py +0 -99
  18. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/.github/workflows/ci.yml +0 -0
  19. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/.github/workflows/publish.yml +0 -0
  20. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/.gitignore +0 -0
  21. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/ARCHITECTURE.md +0 -0
  22. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/README.md +0 -0
  23. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/agent-surfaces.md +0 -0
  24. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/architecture.md +0 -0
  25. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/capabilities.md +0 -0
  26. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/design.md +0 -0
  27. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/installation.md +0 -0
  28. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/rust-architecture.md +0 -0
  29. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/Docs/tools.md +0 -0
  30. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/LICENSE +0 -0
  31. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/README.md +0 -0
  32. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/adapters/claude/.claude-plugin/plugin.json +0 -0
  33. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/adapters/claude/skills/supercoder/SKILL.md +0 -0
  34. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/adapters/codex/config-snippet.toml +0 -0
  35. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/adapters/hermes/README.md +0 -0
  36. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/plugin.yaml +0 -0
  37. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/Cargo.lock +0 -0
  38. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/Cargo.toml +0 -0
  39. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/pyproject.toml +0 -0
  40. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/lib.rs +0 -0
  41. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/main.rs +0 -0
  42. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/outline.rs +0 -0
  43. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/syntax.rs +0 -0
  44. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/__init__.py +0 -0
  45. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/line_budget.py +0 -0
  46. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/lint_gate.py +0 -0
  47. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/queue.py +0 -0
  48. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/repo_map.py +0 -0
  49. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/retry_loop.py +0 -0
  50. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/safety.py +0 -0
  51. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/syntax_gate.py +0 -0
  52. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/test_gate.py +0 -0
  53. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/__init__.py +0 -0
  54. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/catalog.json +0 -0
  55. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/plugin.py +0 -0
  56. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/rust_bridge.py +0 -0
  57. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/schemas.py +0 -0
  58. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_line_budget.py +0 -0
  59. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_lint_gate.py +0 -0
  60. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_plugin.py +0 -0
  61. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_queue.py +0 -0
  62. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_repo_map.py +0 -0
  63. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_retry_loop.py +0 -0
  64. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_rust_bridge.py +0 -0
  65. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_safety.py +0 -0
  66. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_syntax_gate.py +0 -0
  67. {cluxion_agentplugin_supercoder-0.2.7 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_test_gate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cluxion-agentplugin-supercoder
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates.
5
5
  Project-URL: Homepage, https://github.com/cluxion/cluxion-Agentplugin-supercoder
6
6
  Project-URL: Repository, https://github.com/cluxion/cluxion-Agentplugin-supercoder
@@ -9,4 +9,4 @@ if str(_SRC) not in sys.path:
9
9
 
10
10
  from cluxion_agentplugin_supercoder.plugin import register
11
11
 
12
- __all__ = ["register"]
12
+ __all__ = ["register"]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cluxion-agentplugin-supercoder"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -44,11 +44,7 @@ def _dist_info(tree: Path) -> Path:
44
44
 
45
45
  def _native_tags(native_tree: Path) -> list[str]:
46
46
  wheel_meta = (_dist_info(native_tree) / "WHEEL").read_text(encoding="utf-8")
47
- tags = [
48
- line.split(":", 1)[1].strip()
49
- for line in wheel_meta.splitlines()
50
- if line.startswith("Tag:")
51
- ]
47
+ tags = [line.split(":", 1)[1].strip() for line in wheel_meta.splitlines() if line.startswith("Tag:")]
52
48
  if not tags:
53
49
  raise SystemExit("native wheel has no Tag entries")
54
50
  return tags
@@ -52,6 +52,7 @@ def main(argv: Sequence[str] | None = None) -> int:
52
52
  def load_catalog_for_text(catalog_path):
53
53
  # helper to avoid circular, but since framework has load_catalog
54
54
  from cluxion_agentplugin_supercoder.doctor.framework import load_catalog
55
+
55
56
  return load_catalog(Path(str(catalog_path)))
56
57
 
57
58
 
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
8
  from cluxion_agentplugin_supercoder.core.hash_patch import file_hash
9
+ from cluxion_agentplugin_supercoder.core.safety import pre_tool_gate
9
10
 
10
11
 
11
12
  @dataclass(frozen=True)
@@ -27,11 +28,14 @@ def read_window(
27
28
  max_lines: int = 120,
28
29
  purpose: str = "read",
29
30
  ) -> LineWindow:
31
+ gate = pre_tool_gate("read_window", {"path": rel_path}, workspace=root)
32
+ if gate.decision == "block":
33
+ raise PermissionError(gate.reason)
30
34
  path = (root / rel_path).resolve()
31
35
  if not path.exists():
32
36
  raise FileNotFoundError(rel_path)
33
- if not str(path).startswith(str(root.resolve())):
34
- raise PermissionError("path escapes workspace root")
37
+ if not path.is_relative_to(root.resolve()):
38
+ raise PermissionError("workspace escape blocked")
35
39
  text = path.read_text(encoding="utf-8")
36
40
  lines = text.splitlines()
37
41
  start = max(1, start_line)
@@ -77,7 +77,9 @@ def apply_patch(
77
77
  exact = _exact_spans(text, old_text)
78
78
  if exact:
79
79
  start, end = exact[0]
80
- return _commit(path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0)
80
+ return _commit(
81
+ path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0
82
+ )
81
83
  fuzzy = _best_fuzzy_span(text, old_text)
82
84
  if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
83
85
  return _commit(
@@ -172,9 +174,7 @@ def _best_fuzzy_span(text: str, reference: str) -> tuple[int, int, str, float, b
172
174
  def _atomic_write(path: Path, content: str) -> None:
173
175
  """Atomic replace via temp in same dir + fsync to prevent corruption on crash."""
174
176
  dir_ = path.parent
175
- with tempfile.NamedTemporaryFile(
176
- mode="w", encoding="utf-8", dir=dir_, delete=False, suffix=".tmp"
177
- ) as tmp:
177
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=dir_, delete=False, suffix=".tmp") as tmp:
178
178
  tmp.write(content)
179
179
  tmp.flush()
180
180
  os.fsync(tmp.fileno())
@@ -45,14 +45,23 @@ class DoctorResult:
45
45
  version: str
46
46
  checks: tuple[CheckResult, ...]
47
47
 
48
+ @property
49
+ def summary(self) -> str:
50
+ if any(c.status == "fail" for c in self.checks):
51
+ return "fail"
52
+ if any(c.severity == "critical" and c.status == "skip" for c in self.checks):
53
+ return "degraded"
54
+ return "ok"
55
+
48
56
  @property
49
57
  def ok(self) -> bool:
50
- return not any(c.status == "fail" for c in self.checks)
58
+ return self.summary == "ok"
51
59
 
52
60
  def to_json_object(self) -> dict[str, Any]:
53
61
  return {
54
62
  "plugin": self.plugin,
55
63
  "version": self.version,
64
+ "summary": self.summary,
56
65
  "checks": [
57
66
  {
58
67
  "check_id": c.check_id,
@@ -68,9 +77,7 @@ class DoctorResult:
68
77
 
69
78
 
70
79
  class DoctorContext:
71
- def __init__(
72
- self, cwd: Path, hermes_bin: str, run: Callable[[list[str]], subprocess.CompletedProcess]
73
- ) -> None:
80
+ def __init__(self, cwd: Path, hermes_bin: str, run: Callable[[list[str]], subprocess.CompletedProcess]) -> None:
74
81
  self.cwd = cwd
75
82
  self.hermes_bin = hermes_bin
76
83
  self.run = run
@@ -151,14 +158,10 @@ def run_doctor(
151
158
 
152
159
 
153
160
  def render_json(result: DoctorResult) -> str:
154
- return json.dumps(
155
- result.to_json_object(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
156
- )
161
+ return json.dumps(result.to_json_object(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
157
162
 
158
163
 
159
- def render_text(
160
- result: DoctorResult, catalog: tuple[CatalogEntry, ...], *, verbose: bool = False
161
- ) -> str:
164
+ def render_text(result: DoctorResult, catalog: tuple[CatalogEntry, ...], *, verbose: bool = False) -> str:
162
165
  entry_map = {e.check_id: e for e in catalog}
163
166
  lines: list[str] = []
164
167
  for c in result.checks:
@@ -0,0 +1,355 @@
1
+ """Plugin-specific probes for supercoder doctor. Cross-cutting + selected specific checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from .framework import DoctorContext
15
+
16
+ PROBES: dict[str, Callable[[DoctorContext], tuple[str, str]]] = {}
17
+
18
+ _HERMES_ABSENT_SKIP = "hermes binary not on PATH — cannot verify"
19
+
20
+
21
+ def _hermes_path(ctx: DoctorContext) -> str | None:
22
+ return shutil.which(ctx.hermes_bin)
23
+
24
+
25
+ def _register(name: str):
26
+ def deco(fn):
27
+ PROBES[name] = fn
28
+ return fn
29
+
30
+ return deco
31
+
32
+
33
+ @_register("hermes_on_path")
34
+ def hermes_on_path(ctx: DoctorContext) -> tuple[str, str]:
35
+ p = _hermes_path(ctx)
36
+ if p:
37
+ return "pass", str(p)
38
+ return "skip", _HERMES_ABSENT_SKIP
39
+
40
+
41
+ @_register("hermes_version")
42
+ def hermes_version(ctx: DoctorContext) -> tuple[str, str]:
43
+ if _hermes_path(ctx) is None:
44
+ return "skip", _HERMES_ABSENT_SKIP
45
+ try:
46
+ cp = ctx.run([ctx.hermes_bin, "--version"])
47
+ if cp.returncode == 0 and "Hermes Agent v" in cp.stdout:
48
+ return "pass", cp.stdout.strip()
49
+ return "fail", cp.stdout.strip() or cp.stderr.strip()
50
+ except Exception as e:
51
+ return "fail", f"run error: {e}"
52
+
53
+
54
+ @_register("hermes_oneshot_flag")
55
+ def hermes_oneshot_flag(ctx: DoctorContext) -> tuple[str, str]:
56
+ if _hermes_path(ctx) is None:
57
+ return "skip", _HERMES_ABSENT_SKIP
58
+ try:
59
+ cp = ctx.run([ctx.hermes_bin, "--help"])
60
+ out = cp.stdout + cp.stderr
61
+ if "-z" in out and "--oneshot" in out:
62
+ return "pass", "present"
63
+ return "fail", "missing in --help"
64
+ except Exception as e:
65
+ return "fail", f"run error: {e}"
66
+
67
+
68
+ @_register("entry_point_registered")
69
+ def entry_point_registered(ctx: DoctorContext) -> tuple[str, str]:
70
+ try:
71
+ eps = importlib.metadata.entry_points(group="hermes_agent.plugins")
72
+ for ep in eps:
73
+ if "cluxion-agentplugin-supercoder" in (ep.name or "").lower() or "cluxion_agentplugin_supercoder" in (
74
+ ep.value or ""
75
+ ):
76
+ mod = ep.load()
77
+ if hasattr(mod, "register") and callable(mod.register):
78
+ return "pass", ep.value or str(ep)
79
+ return "warn", "entry point metadata not present (dev PYTHONPATH ok)"
80
+ except Exception as e:
81
+ return "fail", f"metadata error: {e}"
82
+
83
+
84
+ @_register("toolset_valid")
85
+ def toolset_valid(ctx: DoctorContext) -> tuple[str, str]:
86
+ if _hermes_path(ctx) is None:
87
+ return "skip", _HERMES_ABSENT_SKIP
88
+ try:
89
+ cp = ctx.run([ctx.hermes_bin, "tools", "list"])
90
+ if cp.returncode == 0 and "supercoder" in cp.stdout:
91
+ return "pass", "supercoder present"
92
+ return "fail", "supercoder not in tools list"
93
+ except Exception as e:
94
+ return "fail", f"run error: {e}"
95
+
96
+
97
+ @_register("install_integrity")
98
+ def install_integrity(ctx: DoctorContext) -> tuple[str, str]:
99
+ try:
100
+ from cluxion_agentplugin_supercoder import __version__ as pkg_version
101
+
102
+ dist_version = importlib.metadata.version("cluxion-agentplugin-supercoder")
103
+ if dist_version == pkg_version:
104
+ return "pass", dist_version
105
+ return "warn", f"dist={dist_version} pkg={pkg_version}"
106
+ except Exception as e:
107
+ return "warn", f"version error: {e}"
108
+
109
+
110
+ @_register("native_module_importable")
111
+ def native_module_importable(ctx: DoctorContext) -> tuple[str, str]:
112
+ try:
113
+ mod = __import__("supercoder_index_native")
114
+ if hasattr(mod, "run"):
115
+ return "pass", "imported (native backend available)"
116
+ return "warn", "imported but expected symbols missing"
117
+ except Exception:
118
+ return "warn", "native missing → using fallback (slower)"
119
+
120
+
121
+ # plugin-specific probes (deterministic ones only) - for supercoder we can add if symbols found
122
+ # for now, handler_exception_coverage is cross-cutting
123
+ @_register("handler_exception_coverage")
124
+ def handler_exception_coverage(ctx: DoctorContext) -> tuple[str, str]:
125
+ try:
126
+ from cluxion_agentplugin_supercoder import runner
127
+ from cluxion_agentplugin_supercoder.plugin import _wrap
128
+
129
+ def bad_cb(_payload: dict[str, object]) -> runner.ToolResult:
130
+ raise TypeError("test TypeError for coverage")
131
+
132
+ result = _wrap(bad_cb)({})
133
+ parsed = json.loads(result)
134
+ if parsed.get("ok") is False and "TypeError" in str(parsed.get("error", "")):
135
+ return "pass", "degraded to error JSON"
136
+ return "fail", f"no error json: {result[:100]}"
137
+ except Exception as e:
138
+ return "skip", f"cannot invoke guard: {e}"
139
+
140
+
141
+ # NEW deterministic probes for previously-skipped catalog checks (import-avail, json-det, abi3/sqlite patterns adapted)
142
+ # hermes_requirements_installed (import availability using find_spec to satisfy linter)
143
+ @_register("hermes_requirements_installed")
144
+ def hermes_requirements_installed(ctx: DoctorContext) -> tuple[str, str]:
145
+ try:
146
+ if importlib.util.find_spec("psutil") and importlib.util.find_spec("yaml"):
147
+ return "pass", "psutil+PyYAML importable"
148
+ return "warn", "missing dep"
149
+ except Exception as e:
150
+ return "skip", f"import check error: {e}"
151
+
152
+
153
+ # repo_map_deterministic (json determinism + real call)
154
+ @_register("repo_map_deterministic")
155
+ def repo_map_deterministic(ctx: DoctorContext) -> tuple[str, str]:
156
+ try:
157
+ from cluxion_agentplugin_supercoder.core.repo_map import build_repo_map
158
+
159
+ m1 = build_repo_map(ctx.cwd, budget_chars=2000)
160
+ m2 = build_repo_map(ctx.cwd, budget_chars=2000)
161
+
162
+ def strip(d):
163
+ if isinstance(d, dict):
164
+ return {k: strip(v) for k, v in d.items() if k != "_stats"}
165
+ if isinstance(d, list):
166
+ return [strip(x) for x in d]
167
+ return d
168
+
169
+ if strip(m1) == strip(m2):
170
+ j1 = json.dumps(m1, sort_keys=True)
171
+ j2 = json.dumps(m2, sort_keys=True)
172
+ if j1 == j2:
173
+ return "pass", "deterministic + json roundtrip ok"
174
+ return "warn", "map match but json not"
175
+ return "fail", "non-deterministic"
176
+ except Exception as e:
177
+ return "skip", f"cannot run: {e}"
178
+
179
+
180
+ # ruff_binary_discoverable (real env/path probe)
181
+ @_register("ruff_binary_discoverable")
182
+ def ruff_binary_discoverable(ctx: DoctorContext) -> tuple[str, str]:
183
+ try:
184
+ envb = os.environ.get("CLUXION_SUPERCODER_RUFF_BIN")
185
+ if envb and Path(envb).is_file():
186
+ return "pass", envb
187
+ cands = [Path(ctx.cwd) / ".venv/bin/ruff", shutil.which("ruff")]
188
+ for c in cands:
189
+ if c and Path(c).is_file():
190
+ return "pass", str(c)
191
+ return "warn", "no ruff binary (advisory)"
192
+ except Exception as e:
193
+ return "skip", f"probe error: {e}"
194
+
195
+
196
+ # file_hash_consistency (real check)
197
+ @_register("file_hash_consistency")
198
+ def file_hash_consistency(ctx: DoctorContext) -> tuple[str, str]:
199
+ try:
200
+ from cluxion_agentplugin_supercoder.core.hash_patch import _normalize_newlines, file_hash
201
+
202
+ c = "a=1\r\nb=2"
203
+ if file_hash(c) == file_hash(_normalize_newlines(c)):
204
+ return "pass", "CRLF safe"
205
+ return "fail", "hash mismatch"
206
+ except Exception as e:
207
+ return "skip", f"hash error: {e}"
208
+
209
+
210
+ _SECRET_BLOCKED = "secret file access blocked"
211
+ _ESCAPE_BLOCKED = "workspace escape blocked"
212
+
213
+
214
+ def _assert_tool_blocks(
215
+ tool_fn: Callable[[dict[str, object]], object],
216
+ *,
217
+ cwd: str,
218
+ path: str,
219
+ expected_error: str,
220
+ extra: dict[str, object] | None = None,
221
+ ) -> str | None:
222
+ from cluxion_agentplugin_supercoder import runner
223
+
224
+ payload: dict[str, object] = {"cwd": cwd, "path": path}
225
+ if extra:
226
+ payload.update(extra)
227
+ result = tool_fn(payload)
228
+ if not isinstance(result, runner.ToolResult):
229
+ return f"unexpected result type for {path}"
230
+ if result.ok or result.payload.get("error") != expected_error:
231
+ return f"{tool_fn.__name__} on {path}: ok={result.ok} error={result.payload.get('error')}"
232
+ return None
233
+
234
+
235
+ @_register("path_security_secrets_blocked")
236
+ def path_security_secrets_blocked(ctx: DoctorContext) -> tuple[str, str]:
237
+ try:
238
+ from cluxion_agentplugin_supercoder import runner
239
+
240
+ with tempfile.TemporaryDirectory() as td:
241
+ root = Path(td)
242
+ (root / ".env").write_text("KEY=secret", encoding="utf-8")
243
+ cred = root / "config" / "credentials"
244
+ cred.mkdir(parents=True)
245
+ (cred / "db.json").write_text("{}", encoding="utf-8")
246
+ for rel in (".env", "config/credentials/db.json"):
247
+ for tool_fn, extra in (
248
+ (runner.read_window_tool, None),
249
+ (runner.patch_tool, {"old_text": "x", "new_text": "y", "syntax_gate": False}),
250
+ ):
251
+ err = _assert_tool_blocks(
252
+ tool_fn,
253
+ cwd=str(root),
254
+ path=rel,
255
+ expected_error=_SECRET_BLOCKED,
256
+ extra=extra,
257
+ )
258
+ if err:
259
+ return "fail", err
260
+ return "pass", "read_window_tool + patch_tool block .env and credentials"
261
+ except Exception as e:
262
+ return "skip", f"cannot run: {e}"
263
+
264
+
265
+ @_register("hermes_context_workspace_root")
266
+ def hermes_context_workspace_root(ctx: DoctorContext) -> tuple[str, str]:
267
+ try:
268
+ from cluxion_agentplugin_supercoder import runner
269
+
270
+ with tempfile.TemporaryDirectory() as td:
271
+ base = Path(td)
272
+ outside = base / "outside"
273
+ outside.mkdir()
274
+ (outside / "secret.txt").write_text("leaked", encoding="utf-8")
275
+ workspace = base / "work"
276
+ workspace.mkdir()
277
+ sibling = base / "work2"
278
+ sibling.mkdir()
279
+ (sibling / "x").write_text("leaked", encoding="utf-8")
280
+ for path in ("../outside/secret.txt", "../work2/x"):
281
+ err = _assert_tool_blocks(
282
+ runner.read_window_tool,
283
+ cwd=str(workspace),
284
+ path=path,
285
+ expected_error=_ESCAPE_BLOCKED,
286
+ )
287
+ if err:
288
+ return "fail", err
289
+ return "pass", "traversal + sibling-prefix escape blocked"
290
+ except Exception as e:
291
+ return "skip", f"cannot run: {e}"
292
+
293
+
294
+ @_register("patch_cursor_validity")
295
+ def patch_cursor_validity(ctx: DoctorContext) -> tuple[str, str]:
296
+ try:
297
+ from cluxion_agentplugin_supercoder import runner
298
+ from cluxion_agentplugin_supercoder.core.cursor import read_window
299
+
300
+ with tempfile.TemporaryDirectory() as td:
301
+ root = Path(td)
302
+ target = root / "t.py"
303
+ target.write_text("x=1", encoding="utf-8")
304
+ window = read_window(root, "t.py")
305
+ target.write_text("x=2", encoding="utf-8")
306
+ result = runner.patch_tool(
307
+ {
308
+ "cwd": str(root),
309
+ "path": "t.py",
310
+ "old_text": "x=1",
311
+ "new_text": "x=3",
312
+ "expected_file_hash": window.file_hash,
313
+ "syntax_gate": False,
314
+ "lint_gate": False,
315
+ }
316
+ )
317
+ if result.ok:
318
+ return "fail", "patch applied with stale hash"
319
+ if result.payload.get("strategy") != "stale_file":
320
+ return "fail", f"expected stale_file, got {result.payload.get('strategy')}"
321
+ return "pass", "stale hash blocked"
322
+ except Exception as e:
323
+ return "skip", f"cannot run: {e}"
324
+
325
+
326
+ @_register("stale_cursor_protection_enforced")
327
+ def stale_cursor_protection_enforced(ctx: DoctorContext) -> tuple[str, str]:
328
+ try:
329
+ from cluxion_agentplugin_supercoder import runner
330
+
331
+ with tempfile.TemporaryDirectory() as td:
332
+ root = Path(td)
333
+ (root / "t.py").write_text("x=1", encoding="utf-8")
334
+ result = runner.patch_tool(
335
+ {
336
+ "cwd": str(root),
337
+ "path": "t.py",
338
+ "old_text": "x",
339
+ "new_text": "y",
340
+ "stale_cursor": True,
341
+ "syntax_gate": False,
342
+ "lint_gate": False,
343
+ }
344
+ )
345
+ if result.ok:
346
+ return "fail", "patch applied with stale_cursor=True"
347
+ error = str(result.payload.get("error", ""))
348
+ if "stale cursor" not in error:
349
+ return "fail", f"unexpected error: {error}"
350
+ return "pass", "stale_cursor flag blocked"
351
+ except Exception as e:
352
+ return "skip", f"cannot run: {e}"
353
+
354
+
355
+ # note: other checks in catalog will be reported as skip (no probe)
@@ -73,6 +73,14 @@ def plan(payload: Mapping[str, object]) -> ToolResult:
73
73
  def read_window_tool(payload: Mapping[str, object]) -> ToolResult:
74
74
  root = _workspace(payload)
75
75
  rel = str(payload.get("path", "")).strip()
76
+ gate = pre_tool_gate(
77
+ "read_window",
78
+ payload,
79
+ workspace=root,
80
+ stale_cursor=bool(payload.get("stale_cursor", False)),
81
+ )
82
+ if gate.decision == "block":
83
+ return ToolResult(False, {"error": gate.reason})
76
84
  start = _int(payload.get("start_line", 1), 1)
77
85
  max_lines = _int(payload.get("max_lines", 120), 120)
78
86
  decision = budget_for("inspect", requested_lines=max_lines)
@@ -182,7 +190,10 @@ def repo_map_tool(payload: Mapping[str, object]) -> ToolResult:
182
190
  result = repo_map.build_repo_map(
183
191
  _workspace(payload),
184
192
  max_files=_int(payload.get("max_files", repo_map.DEFAULT_MAX_FILES), repo_map.DEFAULT_MAX_FILES),
185
- max_symbols_per_file=_int(payload.get("max_symbols_per_file", repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE), repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE),
193
+ max_symbols_per_file=_int(
194
+ payload.get("max_symbols_per_file", repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE),
195
+ repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE,
196
+ ),
186
197
  budget_chars=_int(payload.get("budget_chars", repo_map.DEFAULT_BUDGET_CHARS), repo_map.DEFAULT_BUDGET_CHARS),
187
198
  )
188
199
  return ToolResult(bool(result.get("ok", False)), {key: value for key, value in result.items() if key != "ok"})
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from cluxion_agentplugin_supercoder import runner
8
+ from cluxion_agentplugin_supercoder.core.cursor import read_window
9
+
10
+
11
+ def test_read_window_bounds(tmp_path: Path) -> None:
12
+ path = tmp_path / "sample.py"
13
+ path.write_text("\n".join(f"line{i}" for i in range(1, 201)), encoding="utf-8")
14
+ window = read_window(tmp_path, "sample.py", start_line=10, max_lines=5)
15
+ assert window.start_line == 10
16
+ assert window.end_line == 14
17
+ assert "line10" in window.content
18
+
19
+
20
+ @pytest.mark.parametrize("rel", [".env", "config/credentials/db.json"])
21
+ def test_read_window_blocks_secret_files(tmp_path: Path, rel: str) -> None:
22
+ target = tmp_path / rel
23
+ target.parent.mkdir(parents=True, exist_ok=True)
24
+ target.write_text("AWS_SECRET=leaked", encoding="utf-8")
25
+ with pytest.raises(PermissionError, match="secret file access blocked"):
26
+ read_window(tmp_path, rel)
27
+
28
+
29
+ def test_read_window_blocks_sibling_directory_prefix_escape(tmp_path: Path) -> None:
30
+ workspace = tmp_path / "work"
31
+ workspace.mkdir()
32
+ sibling = tmp_path / "work2"
33
+ sibling.mkdir()
34
+ (sibling / "secret.py").write_text("leaked", encoding="utf-8")
35
+ with pytest.raises(PermissionError, match="workspace escape blocked"):
36
+ read_window(workspace, "../work2/secret.py")
37
+
38
+
39
+ def test_read_window_blocks_plain_traversal(tmp_path: Path) -> None:
40
+ workspace = tmp_path / "work"
41
+ workspace.mkdir()
42
+ with pytest.raises(PermissionError, match="workspace escape blocked"):
43
+ read_window(workspace, "../../etc/passwd")
44
+
45
+
46
+ @pytest.mark.parametrize("rel", [".env", "config/credentials/db.json"])
47
+ def test_read_window_tool_blocks_secret_files(tmp_path: Path, rel: str) -> None:
48
+ target = tmp_path / rel
49
+ target.parent.mkdir(parents=True, exist_ok=True)
50
+ target.write_text("AWS_SECRET=leaked", encoding="utf-8")
51
+ result = runner.read_window_tool({"cwd": str(tmp_path), "path": rel})
52
+ assert result.ok is False
53
+ assert result.payload["error"] == "secret file access blocked"
54
+
55
+
56
+ def test_read_window_tool_blocks_sibling_directory_prefix_escape(tmp_path: Path) -> None:
57
+ workspace = tmp_path / "work"
58
+ workspace.mkdir()
59
+ sibling = tmp_path / "work2"
60
+ sibling.mkdir()
61
+ (sibling / "secret.py").write_text("leaked", encoding="utf-8")
62
+ result = runner.read_window_tool({"cwd": str(workspace), "path": "../work2/secret.py"})
63
+ assert result.ok is False
64
+ assert result.payload["error"] == "workspace escape blocked"
65
+
66
+
67
+ def test_read_window_tool_allows_normal_in_workspace_file(tmp_path: Path) -> None:
68
+ path = tmp_path / "sample.py"
69
+ path.write_text("print('ok')\n", encoding="utf-8")
70
+ result = runner.read_window_tool({"cwd": str(tmp_path), "path": "sample.py"})
71
+ assert result.ok is True
72
+ assert "print('ok')" in str(result.payload["content"])