kekkai-cli 2.0.0__py3-none-any.whl → 2.2.0__py3-none-any.whl

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.
kekkai/cli.py CHANGED
@@ -221,6 +221,17 @@ def main(argv: Sequence[str] | None = None) -> int:
221
221
  type=str,
222
222
  help="Path for .kekkaiignore output (default: .kekkaiignore)",
223
223
  )
224
+ triage_parser.add_argument(
225
+ "--repo",
226
+ type=str,
227
+ help="Repository root path (default: auto-detect from run.json)",
228
+ )
229
+ triage_parser.add_argument(
230
+ "--context-lines",
231
+ type=int,
232
+ default=10,
233
+ help="Context lines before/after line (default: 10, range: 5-100)",
234
+ )
224
235
 
225
236
  # Fix subcommand - AI-powered remediation
226
237
  fix_parser = subparsers.add_parser("fix", help="generate AI-powered code fixes for findings")
@@ -1186,6 +1197,11 @@ def _command_triage(parsed: argparse.Namespace) -> int:
1186
1197
 
1187
1198
  input_path_str = cast(str | None, getattr(parsed, "input", None))
1188
1199
  output_path_str = cast(str | None, getattr(parsed, "output", None))
1200
+ repo_path_str = cast(str | None, getattr(parsed, "repo", None))
1201
+ context_lines = cast(int, getattr(parsed, "context_lines", 10))
1202
+
1203
+ # Validate context_lines range
1204
+ context_lines = max(5, min(100, context_lines))
1189
1205
 
1190
1206
  # Default to latest run if no input specified
1191
1207
  if not input_path_str:
@@ -1232,7 +1248,36 @@ def _command_triage(parsed: argparse.Namespace) -> int:
1232
1248
 
1233
1249
  console.print(f"[info]Loaded {len(findings)} finding(s)[/info]\n")
1234
1250
 
1235
- return run_triage(findings=findings, output_path=output_path)
1251
+ # Auto-detect repo_path from run.json if not explicitly provided
1252
+ repo_path: Path | None = None
1253
+ if repo_path_str:
1254
+ repo_path = Path(repo_path_str).expanduser().resolve()
1255
+ elif input_path.is_dir():
1256
+ # Try to read repo_path from run.json
1257
+ manifest_path = input_path / "run.json"
1258
+ if manifest_path.exists():
1259
+ try:
1260
+ import json
1261
+
1262
+ with manifest_path.open() as f:
1263
+ manifest_data = json.load(f)
1264
+ stored_repo = manifest_data.get("repo_path")
1265
+ if stored_repo:
1266
+ repo_path = Path(stored_repo).expanduser().resolve()
1267
+ console.print(f"[dim]Using repo path from run metadata: {repo_path}[/dim]\n")
1268
+ except (OSError, json.JSONDecodeError, KeyError):
1269
+ pass
1270
+
1271
+ # Fall back to current directory if still not set
1272
+ if repo_path is None:
1273
+ repo_path = Path.cwd()
1274
+
1275
+ return run_triage(
1276
+ findings=findings,
1277
+ output_path=output_path,
1278
+ repo_path=repo_path,
1279
+ context_lines=context_lines,
1280
+ )
1236
1281
 
1237
1282
 
1238
1283
  def _command_fix(parsed: argparse.Namespace) -> int:
kekkai/output.py CHANGED
@@ -57,7 +57,7 @@ BANNER_ASCII = r"""
57
57
  /_/\_\\___/_/\_/_/\_\\_,_/_/
58
58
  """
59
59
 
60
- VERSION = "2.0.0"
60
+ VERSION = "2.2.0"
61
61
 
62
62
 
63
63
  def print_dashboard() -> None:
kekkai/triage/__init__.py CHANGED
@@ -29,6 +29,8 @@ def run_triage(
29
29
  input_path: Path | None = None,
30
30
  output_path: Path | None = None,
31
31
  findings: Sequence[FindingEntry] | None = None,
32
+ repo_path: Path | None = None,
33
+ context_lines: int = 10,
32
34
  ) -> int:
33
35
  """Run the triage TUI (lazy import).
34
36
 
@@ -36,6 +38,8 @@ def run_triage(
36
38
  input_path: Path to findings JSON file.
37
39
  output_path: Path for .kekkaiignore output.
38
40
  findings: Pre-loaded findings (alternative to input_path).
41
+ repo_path: Repository root path for code context display.
42
+ context_lines: Number of lines to show before/after vulnerable line.
39
43
 
40
44
  Returns:
41
45
  Exit code (0 for success).
@@ -50,6 +54,8 @@ def run_triage(
50
54
  input_path=input_path,
51
55
  output_path=output_path,
52
56
  findings=findings,
57
+ repo_path=repo_path,
58
+ context_lines=context_lines,
53
59
  )
54
60
  except ImportError as e:
55
61
  raise RuntimeError(
kekkai/triage/app.py CHANGED
@@ -51,6 +51,8 @@ class TriageApp(App[None]):
51
51
  input_path: Path | None = None,
52
52
  output_path: Path | None = None,
53
53
  audit_path: Path | None = None,
54
+ repo_path: Path | None = None,
55
+ context_lines: int = 10,
54
56
  ) -> None:
55
57
  """Initialize triage application.
56
58
 
@@ -59,6 +61,8 @@ class TriageApp(App[None]):
59
61
  input_path: Path to findings JSON file.
60
62
  output_path: Path for .kekkaiignore output.
61
63
  audit_path: Path for audit log.
64
+ repo_path: Repository root path for code context display.
65
+ context_lines: Number of lines to show before/after vulnerable line.
62
66
  """
63
67
  super().__init__()
64
68
  self._input_path = input_path
@@ -66,6 +70,8 @@ class TriageApp(App[None]):
66
70
  self.ignore_file = IgnoreFile(output_path)
67
71
  self.audit_log = TriageAuditLog(audit_path)
68
72
  self._decisions: dict[str, TriageDecision] = {}
73
+ self.repo_path = repo_path or Path.cwd()
74
+ self.context_lines = context_lines
69
75
 
70
76
  @property
71
77
  def findings(self) -> list[FindingEntry]:
@@ -148,6 +154,8 @@ def run_triage(
148
154
  input_path: Path | None = None,
149
155
  output_path: Path | None = None,
150
156
  findings: Sequence[FindingEntry] | None = None,
157
+ repo_path: Path | None = None,
158
+ context_lines: int = 10,
151
159
  ) -> int:
152
160
  """Run the triage TUI.
153
161
 
@@ -155,6 +163,8 @@ def run_triage(
155
163
  input_path: Path to findings JSON file.
156
164
  output_path: Path for .kekkaiignore output.
157
165
  findings: Pre-loaded findings (alternative to input_path).
166
+ repo_path: Repository root path for code context display.
167
+ context_lines: Number of lines to show before/after vulnerable line.
158
168
 
159
169
  Returns:
160
170
  Exit code (0 for success).
@@ -163,6 +173,8 @@ def run_triage(
163
173
  findings=findings,
164
174
  input_path=input_path,
165
175
  output_path=output_path,
176
+ repo_path=repo_path,
177
+ context_lines=context_lines,
166
178
  )
167
179
  app.run()
168
180
  return 0
@@ -0,0 +1,345 @@
1
+ """Code context extraction for triage TUI.
2
+
3
+ Provides secure code context extraction with security controls:
4
+ - Path traversal protection (ASVS V5.3.3)
5
+ - File size limits (ASVS V10.3.3)
6
+ - Sensitive file detection (ASVS V8.3.4)
7
+ - Error sanitization (ASVS V7.4.1)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from ..fix.prompts import FixPromptBuilder
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = [
21
+ "CodeContext",
22
+ "CodeContextExtractor",
23
+ ]
24
+
25
+ # Security limits per ASVS V10.3.3
26
+ MAX_FILE_SIZE_MB = 10
27
+
28
+ # Sensitive file extensions to block (ASVS V8.3.4)
29
+ SENSITIVE_EXTENSIONS = {
30
+ ".env",
31
+ ".pem",
32
+ ".key",
33
+ ".crt",
34
+ ".p12",
35
+ ".pfx",
36
+ ".jks",
37
+ ".keystore",
38
+ ".pub",
39
+ "id_rsa",
40
+ "id_dsa",
41
+ "id_ecdsa",
42
+ "id_ed25519",
43
+ }
44
+
45
+ # Binary file extensions to skip
46
+ BINARY_EXTENSIONS = {
47
+ ".pyc",
48
+ ".pyo",
49
+ ".so",
50
+ ".dll",
51
+ ".dylib",
52
+ ".exe",
53
+ ".bin",
54
+ ".class",
55
+ ".jar",
56
+ ".war",
57
+ ".ear",
58
+ ".png",
59
+ ".jpg",
60
+ ".jpeg",
61
+ ".gif",
62
+ ".bmp",
63
+ ".ico",
64
+ ".pdf",
65
+ ".zip",
66
+ ".tar",
67
+ ".gz",
68
+ ".bz2",
69
+ ".7z",
70
+ ".rar",
71
+ ".whl",
72
+ ".egg",
73
+ }
74
+
75
+
76
+ @dataclass
77
+ class CodeContext:
78
+ """Code context with syntax highlighting metadata.
79
+
80
+ Attributes:
81
+ code: Formatted code with line numbers and >>> marker.
82
+ language: Detected programming language for syntax highlighting.
83
+ vulnerable_line: The specific vulnerable line text.
84
+ error: Error message if extraction failed (None on success).
85
+ """
86
+
87
+ code: str
88
+ language: str
89
+ vulnerable_line: str
90
+ error: str | None = None
91
+
92
+
93
+ class CodeContextExtractor:
94
+ """Extracts code context from files with security controls.
95
+
96
+ Security features:
97
+ - Path validation to prevent traversal attacks (ASVS V5.3.3)
98
+ - File size limits to prevent DoS (ASVS V10.3.3)
99
+ - Sensitive file detection (ASVS V8.3.4)
100
+ - Sanitized error messages (ASVS V7.4.1)
101
+
102
+ Performance features:
103
+ - LRU cache for file contents (max 20 files, ~200KB per file = ~4MB total)
104
+ - Reduces re-reads when navigating between findings in same files
105
+ """
106
+
107
+ def __init__(self, repo_path: Path, max_file_size_mb: int = MAX_FILE_SIZE_MB) -> None:
108
+ """Initialize code context extractor.
109
+
110
+ Args:
111
+ repo_path: Repository root path (for path validation).
112
+ max_file_size_mb: Maximum file size in MB (DoS protection).
113
+ """
114
+ self.repo_path = repo_path.resolve()
115
+ self.max_file_size_mb = max_file_size_mb
116
+ self._prompt_builder = FixPromptBuilder(context_lines=10)
117
+ # Simple LRU cache: {file_path: file_content}
118
+ # Limited to 20 files to prevent memory bloat
119
+ self._file_cache: dict[str, str] = {}
120
+ self._cache_max_size = 20
121
+
122
+ def extract(self, file_path: str, line: int | None) -> CodeContext | None:
123
+ """Extract code context from a file.
124
+
125
+ Args:
126
+ file_path: Relative or absolute path to the file.
127
+ line: Line number (1-indexed) of the vulnerability.
128
+
129
+ Returns:
130
+ CodeContext object with code and metadata, or None if unavailable.
131
+
132
+ Security:
133
+ - Path validation (ASVS V5.3.3)
134
+ - Size limits (ASVS V10.3.3)
135
+ - Sensitive file blocking (ASVS V8.3.4)
136
+ - Error sanitization (ASVS V7.4.1)
137
+ """
138
+ if not file_path or not line:
139
+ return None
140
+
141
+ # Resolve path
142
+ try:
143
+ full_path = (self.repo_path / file_path).resolve()
144
+ except (ValueError, OSError):
145
+ # ASVS V7.4.1: Sanitized error (no full path)
146
+ logger.warning(
147
+ "code_context_path_invalid",
148
+ extra={"file_path": Path(file_path).name, "reason": "invalid_path"},
149
+ )
150
+ return CodeContext(
151
+ code="",
152
+ language="",
153
+ vulnerable_line="",
154
+ error="Invalid file path",
155
+ )
156
+
157
+ # ASVS V5.3.3: Path traversal check
158
+ if not self._validate_path(full_path):
159
+ logger.warning(
160
+ "code_context_path_traversal",
161
+ extra={"file_path": Path(file_path).name, "reason": "path_traversal"},
162
+ )
163
+ return None
164
+
165
+ # Check if file exists
166
+ if not full_path.exists():
167
+ logger.info(
168
+ "code_context_file_not_found",
169
+ extra={"file_path": Path(file_path).name},
170
+ )
171
+ return CodeContext(
172
+ code="",
173
+ language="",
174
+ vulnerable_line="",
175
+ error="File not found",
176
+ )
177
+
178
+ # ASVS V8.3.4: Sensitive file check
179
+ if self._is_sensitive_file(full_path):
180
+ logger.info(
181
+ "code_context_sensitive_file",
182
+ extra={"file_path": Path(file_path).name},
183
+ )
184
+ return CodeContext(
185
+ code="",
186
+ language="",
187
+ vulnerable_line="",
188
+ error="Code hidden (sensitive file type)",
189
+ )
190
+
191
+ # Binary file check
192
+ if not self._is_text_file(full_path):
193
+ logger.info(
194
+ "code_context_binary_file",
195
+ extra={"file_path": Path(file_path).name},
196
+ )
197
+ return None
198
+
199
+ # ASVS V10.3.3: File size check
200
+ size_mb = full_path.stat().st_size / (1024 * 1024)
201
+ if size_mb > self.max_file_size_mb:
202
+ logger.warning(
203
+ "code_context_file_too_large",
204
+ extra={"file_path": Path(file_path).name, "size_mb": f"{size_mb:.1f}"},
205
+ )
206
+ return CodeContext(
207
+ code="",
208
+ language="",
209
+ vulnerable_line="",
210
+ error=f"File too large for display ({size_mb:.1f}MB)",
211
+ )
212
+
213
+ # Read file content (with caching for performance)
214
+ cache_key = str(full_path)
215
+ if cache_key in self._file_cache:
216
+ file_content = self._file_cache[cache_key]
217
+ else:
218
+ try:
219
+ 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
229
+ logger.warning(
230
+ "code_context_read_error",
231
+ extra={"file_path": Path(file_path).name, "error": str(e)},
232
+ )
233
+ return CodeContext(
234
+ code="",
235
+ language="",
236
+ vulnerable_line="",
237
+ error="Cannot read file",
238
+ )
239
+
240
+ # Extract code context using existing logic from fix engine
241
+ code_context, vulnerable_line = self._prompt_builder.extract_code_context(
242
+ file_content, line
243
+ )
244
+
245
+ # Detect language for syntax highlighting
246
+ language = self._detect_language(full_path)
247
+
248
+ return CodeContext(
249
+ code=code_context,
250
+ language=language,
251
+ vulnerable_line=vulnerable_line,
252
+ error=None,
253
+ )
254
+
255
+ def _validate_path(self, path: Path) -> bool:
256
+ """Validate that path is within repo_path (prevent traversal).
257
+
258
+ Args:
259
+ path: Resolved path to validate.
260
+
261
+ Returns:
262
+ True if path is safe, False otherwise.
263
+
264
+ Security:
265
+ ASVS V5.3.3: Path validation to prevent directory traversal.
266
+ """
267
+ try:
268
+ # Check if path is within repo_path
269
+ path.relative_to(self.repo_path)
270
+ return True
271
+ except ValueError:
272
+ return False
273
+
274
+ def _is_text_file(self, path: Path) -> bool:
275
+ """Check if file is a text file (not binary).
276
+
277
+ Args:
278
+ path: Path to check.
279
+
280
+ Returns:
281
+ True if likely a text file, False if binary.
282
+ """
283
+ suffix = path.suffix.lower()
284
+ name = path.name.lower()
285
+
286
+ # Check against binary extensions
287
+ if suffix in BINARY_EXTENSIONS:
288
+ return False
289
+
290
+ # Special cases without extensions
291
+ if name in ("dockerfile", "makefile", "vagrantfile", "jenkinsfile"):
292
+ return True
293
+
294
+ return True
295
+
296
+ def _is_sensitive_file(self, path: Path) -> bool:
297
+ """Check if file contains sensitive data (secrets, keys).
298
+
299
+ Args:
300
+ path: Path to check.
301
+
302
+ Returns:
303
+ True if file is sensitive and should not be displayed.
304
+
305
+ Security:
306
+ ASVS V8.3.4: Prevent sensitive data in outputs.
307
+ """
308
+ suffix = path.suffix.lower()
309
+ name = path.name.lower()
310
+
311
+ # Check extension
312
+ if suffix in SENSITIVE_EXTENSIONS:
313
+ return True
314
+
315
+ # Check if the entire filename (including leading dot) matches sensitive patterns
316
+ # For files like .env, .pem, etc., the suffix is empty but name includes the dot
317
+ if name in {".env", ".pem", ".key", ".crt"}:
318
+ return True
319
+
320
+ # Check filename patterns
321
+ return any(
322
+ pattern in name
323
+ for pattern in [
324
+ "secret",
325
+ "credential",
326
+ "password",
327
+ "token",
328
+ "apikey",
329
+ "private_key",
330
+ "id_rsa",
331
+ "id_dsa",
332
+ "id_ecdsa",
333
+ ]
334
+ )
335
+
336
+ def _detect_language(self, path: Path) -> str:
337
+ """Detect programming language from file extension.
338
+
339
+ Args:
340
+ path: Path to the file.
341
+
342
+ Returns:
343
+ Language identifier for syntax highlighting.
344
+ """
345
+ return self._prompt_builder._detect_language(str(path))
@@ -213,17 +213,47 @@ class FixGenerationScreen(ModalScreen[bool]):
213
213
  self.fix_generated = False
214
214
 
215
215
  def action_accept(self) -> None:
216
- """Accept and apply the generated fix."""
216
+ """Accept and apply the generated fix (workbench: step 3 - verification)."""
217
217
  if not self.fix_generated:
218
218
  return
219
219
 
220
+ # Generate post-fix verification hints
221
+ verification_message = self._get_verification_hints()
222
+
220
223
  if self.on_fix_generated:
221
- self.on_fix_generated(
222
- True, "Fix generated successfully (dry-run mode - review before applying)"
223
- )
224
+ self.on_fix_generated(True, verification_message)
224
225
 
225
226
  self.dismiss(True)
226
227
 
228
+ def _get_verification_hints(self) -> str:
229
+ """Generate next-steps hints after fix is applied."""
230
+ file_path = self.finding.file_path or "file"
231
+ file_name = file_path.split("/")[-1] if "/" in file_path else file_path
232
+
233
+ # Detect likely test command based on file extension
234
+ test_cmd = "pytest tests/"
235
+ if file_path.endswith(".js") or file_path.endswith(".ts"):
236
+ test_cmd = "npm test"
237
+ elif file_path.endswith(".go"):
238
+ test_cmd = "go test ./..."
239
+ elif file_path.endswith(".rs"):
240
+ test_cmd = "cargo test"
241
+ elif file_path.endswith(".java"):
242
+ test_cmd = "mvn test"
243
+
244
+ hints = [
245
+ f"✓ Fix applied to {file_name}",
246
+ "",
247
+ "📋 Next steps:",
248
+ f" 1. Run tests: {test_cmd}",
249
+ " 2. Re-scan: kekkai scan --repo .",
250
+ f" 3. Commit: git add {file_path} && git commit -m 'fix: {self.finding.title[:50]}'",
251
+ "",
252
+ "💡 Tip: Review the changes carefully before committing!",
253
+ ]
254
+
255
+ return "\n".join(hints)
256
+
227
257
  def action_cancel(self) -> None:
228
258
  """Cancel fix generation."""
229
259
  if self.on_fix_generated:
kekkai/triage/screens.py CHANGED
@@ -6,8 +6,10 @@ with keyboard navigation and action handling.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from pathlib import Path
9
10
  from typing import TYPE_CHECKING
10
11
 
12
+ from rich.syntax import Syntax
11
13
  from rich.text import Text
12
14
  from textual.app import ComposeResult
13
15
  from textual.binding import Binding
@@ -15,6 +17,7 @@ from textual.containers import Vertical, VerticalScroll
15
17
  from textual.screen import Screen
16
18
  from textual.widgets import Footer, Header, Label, Static, TextArea
17
19
 
20
+ from .code_context import CodeContextExtractor
18
21
  from .models import TriageState
19
22
  from .widgets import FindingCard, sanitize_display
20
23
 
@@ -149,10 +152,15 @@ class FindingListScreen(Screen[None]):
149
152
  if not self.findings:
150
153
  return
151
154
  finding = self.findings[self.selected_index]
155
+ # Get repo_path and context_lines from app if available
156
+ repo_path = getattr(self.app, "repo_path", None)
157
+ context_lines = getattr(self.app, "context_lines", 10)
152
158
  self.app.push_screen(
153
159
  FindingDetailScreen(
154
160
  finding,
155
161
  on_state_change=self._handle_detail_state_change,
162
+ repo_path=repo_path,
163
+ context_lines=context_lines,
156
164
  )
157
165
  )
158
166
 
@@ -212,6 +220,10 @@ class FindingDetailScreen(Screen[None]):
212
220
  """
213
221
 
214
222
  BINDINGS = [
223
+ Binding("x", "fix_with_ai", "🤖 AI Fix"),
224
+ Binding("ctrl+o", "open_in_editor", "Open in Editor"),
225
+ Binding("e", "expand_context", "Expand Context"),
226
+ Binding("s", "shrink_context", "Shrink Context"),
215
227
  Binding("f", "mark_false_positive", "False Positive"),
216
228
  Binding("c", "mark_confirmed", "Confirmed"),
217
229
  Binding("d", "mark_deferred", "Deferred"),
@@ -234,6 +246,24 @@ class FindingDetailScreen(Screen[None]):
234
246
  padding: 1;
235
247
  border: solid $primary;
236
248
  }
249
+ #code-context-display {
250
+ height: 20;
251
+ border: solid $accent;
252
+ padding: 1;
253
+ margin: 1 0;
254
+ overflow-y: scroll;
255
+ }
256
+ .error-message {
257
+ color: $warning;
258
+ italic: true;
259
+ }
260
+ #action-hints {
261
+ height: auto;
262
+ padding: 1;
263
+ margin: 1 0;
264
+ background: $panel;
265
+ border: solid $secondary;
266
+ }
237
267
  #notes-area {
238
268
  height: 8;
239
269
  margin-top: 1;
@@ -245,12 +275,17 @@ class FindingDetailScreen(Screen[None]):
245
275
  self,
246
276
  finding: FindingEntry,
247
277
  on_state_change: Callable[[TriageState, str], None] | None = None,
278
+ repo_path: Path | None = None,
279
+ context_lines: int = 10,
248
280
  name: str | None = None,
249
281
  id: str | None = None,
250
282
  ) -> None:
251
283
  super().__init__(name=name, id=id)
252
284
  self.finding = finding
253
285
  self.on_state_change = on_state_change
286
+ self.repo_path = repo_path or Path.cwd()
287
+ self.context_lines = context_lines
288
+ self._code_extractor = CodeContextExtractor(self.repo_path)
254
289
 
255
290
  def compose(self) -> ComposeResult:
256
291
  yield Header()
@@ -258,6 +293,13 @@ class FindingDetailScreen(Screen[None]):
258
293
  yield Static(self._header_text(), id="detail-header")
259
294
  with VerticalScroll(id="detail-content"):
260
295
  yield Static(self._detail_text())
296
+ # Add code context if available
297
+ code_widget = self._render_code_context()
298
+ if code_widget:
299
+ yield Label("Code Context:")
300
+ yield code_widget
301
+ # Add action hints to make workflow discoverable
302
+ yield Static(self._action_hints(), id="action-hints")
261
303
  yield Label("Notes (will be saved with decision):")
262
304
  yield TextArea(self.finding.notes, id="notes-area")
263
305
  yield Footer()
@@ -340,3 +382,186 @@ class FindingDetailScreen(Screen[None]):
340
382
  def action_go_back(self) -> None:
341
383
  """Go back to list screen."""
342
384
  self.app.pop_screen()
385
+
386
+ def _action_hints(self) -> Text:
387
+ """Generate action hints to make workflow discoverable."""
388
+ text = Text()
389
+ text.append("💡 Actions: ", style="bold")
390
+ text.append("Press ", style="dim")
391
+ text.append("X", style="bold cyan")
392
+ text.append(" for AI-powered fix | ", style="dim")
393
+ text.append("Ctrl+O", style="bold cyan")
394
+ text.append(" to open in ", style="dim")
395
+ text.append("$EDITOR", style="italic")
396
+ text.append(" | ", style="dim")
397
+ text.append("E", style="bold cyan")
398
+ text.append("/", style="dim")
399
+ text.append("S", style="bold cyan")
400
+ text.append(" to expand/shrink context", style="dim")
401
+ return text
402
+
403
+ def action_fix_with_ai(self) -> None:
404
+ """Trigger AI-powered fix generation (workbench: step 2)."""
405
+ # Check if file path and line exist (required for fix)
406
+ if not self.finding.file_path or not self.finding.line:
407
+ self.notify(
408
+ "Cannot generate fix: no file path or line number",
409
+ severity="warning",
410
+ )
411
+ return
412
+
413
+ # Import and show fix generation screen
414
+ try:
415
+ from .fix_screen import FixGenerationScreen
416
+
417
+ def on_fix_result(accepted: bool, preview: str) -> None:
418
+ if accepted:
419
+ self.notify("Fix generation completed!", severity="information")
420
+ else:
421
+ self.notify("Fix generation cancelled", severity="information")
422
+
423
+ self.app.push_screen(
424
+ FixGenerationScreen(
425
+ finding=self.finding,
426
+ on_fix_generated=on_fix_result,
427
+ )
428
+ )
429
+ except ImportError:
430
+ self.notify("AI fix feature not available", severity="error")
431
+
432
+ def action_open_in_editor(self) -> None:
433
+ """Open file in external $EDITOR at the vulnerable line (workbench: step 2)."""
434
+ import logging
435
+ import os
436
+ import shutil
437
+ import subprocess
438
+
439
+ logger = logging.getLogger(__name__)
440
+
441
+ if not self.finding.file_path or not self.finding.line:
442
+ self.notify(
443
+ "Cannot open editor: no file path or line number",
444
+ severity="warning",
445
+ )
446
+ return
447
+
448
+ # Get editor from environment (ASVS V5.1.3: validate before use)
449
+ editor = os.environ.get("EDITOR", "vim")
450
+
451
+ # Security validation: check editor exists and is executable
452
+ editor_path = shutil.which(editor)
453
+ if not editor_path:
454
+ self.notify(
455
+ f"Editor '{editor}' not found. Set $EDITOR environment variable.",
456
+ severity="error",
457
+ )
458
+ return
459
+
460
+ # Construct file path relative to repo
461
+ file_path = self.repo_path / self.finding.file_path
462
+ if not file_path.exists():
463
+ self.notify(f"File not found: {self.finding.file_path}", severity="error")
464
+ return
465
+
466
+ # Log editor invocation (ASVS V16.7.1)
467
+ logger.info(
468
+ "editor_opened",
469
+ extra={
470
+ "editor": editor,
471
+ "file": self.finding.file_path,
472
+ "line": self.finding.line,
473
+ },
474
+ )
475
+
476
+ # ASVS V14.2.1: Use list args (not shell=True) to prevent injection
477
+ cmd = [editor_path, f"+{self.finding.line}", str(file_path)]
478
+
479
+ try:
480
+ # Suspend TUI, run editor, then resume
481
+ with self.app.suspend():
482
+ # S603: subprocess with validated input (editor_path checked via shutil.which)
483
+ result = subprocess.run(cmd, check=False) # noqa: S603
484
+ if result.returncode != 0:
485
+ self.notify(
486
+ f"Editor exited with code {result.returncode}",
487
+ severity="warning",
488
+ )
489
+ except Exception as e:
490
+ logger.error("editor_failed", extra={"error": str(e)})
491
+ self.notify(f"Failed to open editor: {e}", severity="error")
492
+
493
+ def action_expand_context(self) -> None:
494
+ """Expand code context by 10 lines (addresses keyhole effect)."""
495
+ self.context_lines = min(100, self.context_lines + 10)
496
+ self._refresh_code_context()
497
+ self.notify(f"Context expanded to {self.context_lines} lines", severity="information")
498
+
499
+ def action_shrink_context(self) -> None:
500
+ """Shrink code context by 10 lines."""
501
+ self.context_lines = max(5, self.context_lines - 10)
502
+ self._refresh_code_context()
503
+ self.notify(f"Context shrunk to {self.context_lines} lines", severity="information")
504
+
505
+ def _refresh_code_context(self) -> None:
506
+ """Refresh code context display with new context_lines setting."""
507
+ import logging
508
+
509
+ logger = logging.getLogger(__name__)
510
+
511
+ try:
512
+ # Update the prompt builder's context lines
513
+ self._code_extractor._prompt_builder.context_lines = self.context_lines
514
+
515
+ # Re-render code context
516
+ code_widget = self._render_code_context()
517
+ if code_widget:
518
+ # Find and update the existing widget
519
+ try:
520
+ old_widget = self.query_one("#code-context-display")
521
+ # Replace content by removing old and adding new
522
+ old_widget.remove()
523
+ # Find the label and insert after it
524
+ container = self.query_one("#detail-container")
525
+ container.mount(code_widget)
526
+ except Exception as e:
527
+ # Widget not found or couldn't refresh
528
+ logger.debug("code_context_refresh_failed", extra={"error": str(e)})
529
+ except Exception as e:
530
+ # Refresh failed, log but don't crash
531
+ logger.debug("code_context_update_failed", extra={"error": str(e)})
532
+
533
+ def _render_code_context(self) -> Static | None:
534
+ """Render code context with syntax highlighting.
535
+
536
+ Returns:
537
+ Static widget with Syntax-highlighted code, or None if unavailable.
538
+
539
+ Security:
540
+ - File size limit: 10MB (ASVS V10.3.3)
541
+ - Path validation: must be within repo_path (ASVS V5.3.3)
542
+ - Error sanitization: no full paths in errors (ASVS V7.4.1)
543
+ - Sensitive file blocking (ASVS V8.3.4)
544
+ """
545
+ if not self.finding.file_path or not self.finding.line:
546
+ return None
547
+
548
+ context = self._code_extractor.extract(self.finding.file_path, self.finding.line)
549
+ if not context:
550
+ return None
551
+
552
+ if context.error:
553
+ return Static(f"Code unavailable: {context.error}", classes="error-message")
554
+
555
+ # Create syntax-highlighted code display
556
+ try:
557
+ syntax = Syntax(
558
+ context.code,
559
+ context.language,
560
+ line_numbers=False, # We already have line numbers in the context
561
+ theme="monokai",
562
+ word_wrap=False,
563
+ )
564
+ return Static(syntax, id="code-context-display")
565
+ except Exception:
566
+ # Fallback to plain text if syntax highlighting fails
567
+ return Static(context.code, id="code-context-display")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kekkai-cli
3
- Version: 2.0.0
3
+ Version: 2.2.0
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
@@ -8,30 +8,32 @@ Requires-Dist: rich>=13.0.0
8
8
  Requires-Dist: jsonschema>=4.20.0
9
9
  Requires-Dist: textual>=0.50.0
10
10
  Requires-Dist: httpx>=0.24.0
11
+ Requires-Dist: jinja2>=3.1.6
11
12
 
12
13
  <p align="center">
13
14
  <img src="https://raw.githubusercontent.com/kademoslabs/assets/main/logos/kekkai-slim.png" alt="Kekkai CLI Logo" width="250"/>
14
15
  </p>
15
16
 
16
- <p align="center"><strong>Stop parsing JSON. Security triage in your terminal.</strong></p>
17
+ <p align="center"><strong>Interactive security triage in the terminal.</strong></p>
17
18
 
18
19
  <p align="center">
19
20
  <img src="https://img.shields.io/github/actions/workflow/status/kademoslabs/kekkai/docker-publish.yml?logo=github"/>
20
21
  <img src="https://img.shields.io/circleci/build/github/kademoslabs/kekkai?logo=circleci"/>
21
- <img src="https://img.shields.io/pypi/v/kekkai-cli?pypiBaseUrl=https%3A%2F%2Fpypi.org&logo=pypi"/>
22
+ <img alt="PyPI - Version" src="https://img.shields.io/pypi/v/kekkai-cli">
23
+
22
24
  </p>
23
25
 
24
26
  ---
25
27
 
26
28
  # Kekkai
27
29
 
28
- **Interactive security triage in the terminal.**
30
+ **Stop parsing JSON.**
29
31
 
30
32
  Kekkai is a small open-source CLI that wraps existing security scanners (Trivy, Semgrep, Gitleaks) and focuses on the part that tends to be slow and frustrating: reviewing and triaging results.
31
33
 
32
34
  Running scanners is easy. Interpreting noisy output, dealing with false positives, and making CI usable is not. Kekkai exists to make that part tolerable..
33
35
 
34
- ![Hero GIF](https://raw.githubusercontent.com/kademoslabs/assets/main/screenshots/kekkai-start.gif)
36
+ ![Hero GIF](https://raw.githubusercontent.com/kademoslabs/assets/main/screenshots/kekkai.gif)
35
37
 
36
38
  ---
37
39
 
@@ -72,6 +74,17 @@ kekkai triage
72
74
  # Interactive TUI to review findings with keyboard navigation
73
75
  ```
74
76
 
77
+ ### ⚡️ Auto-Install (Pre-commit)
78
+
79
+ Add this to your `.pre-commit-config.yaml` to scan on every commit:
80
+
81
+ ```yaml
82
+ - repo: [https://github.com/kademoslabs/kekkai](https://github.com/kademoslabs/kekkai)
83
+ rev: v2.0.1
84
+ hooks:
85
+ - id: kekkai-scan
86
+ ```
87
+
75
88
  No signup, no cloud service required.
76
89
 
77
90
  ---
@@ -107,40 +120,17 @@ kekkai triage
107
120
  - `Ctrl+S`: Save decisions
108
121
  - `q`: Quit
109
122
 
110
- <!-- Screenshot placeholder: ![Triage TUI](https://raw.githubusercontent.com/kademoslabs/assets/main/screenshots/triage-tui.png) -->
123
+ ![Triage TUI](https://raw.githubusercontent.com/kademoslabs/assets/main/screenshots/kekkai-triage.png)
111
124
 
112
125
  [Full Triage Documentation →](docs/triage/README.md)
113
126
 
114
127
  ---
115
128
 
116
- ### CI/CD Policy Gate
117
-
118
- Break builds on severity thresholds.
119
-
120
- Kekkai can be used as a CI gate based on severity thresholds.
129
+ ### 🚦 CI/CD in 1 Second
121
130
 
131
+ Don't write YAML. Run this in your repo:
122
132
  ```bash
123
- # Fail on any critical or high findings
124
- kekkai scan --ci --fail-on high
125
-
126
- # Fail only on critical
127
- kekkai scan --ci --fail-on critical
128
- ```
129
-
130
- **Exit Codes:**
131
- | Code | Meaning |
132
- |------|---------|
133
- | 0 | No findings above threshold |
134
- | 1 | Findings exceed threshold |
135
- | 2 | Scanner error |
136
-
137
- **GitHub Actions Example:**
138
-
139
- ```yaml
140
- - name: Security Scan
141
- run: |
142
- pipx install kekkai-cli
143
- kekkai scan --ci --fail-on high
133
+ kekkai init --ci
144
134
  ```
145
135
 
146
136
  [Full CI Documentation →](docs/ci/ci-mode.md)
@@ -1,10 +1,10 @@
1
1
  kekkai/__init__.py,sha256=_VrBvJRyqHiXs31S8HOhATk_O2iy-ac0_9X7rHH75j8,143
2
- kekkai/cli.py,sha256=Y99dHzSRLV4sqbFiSe81nJRtvx2dQWmRDPyOVdghIIQ,66616
2
+ kekkai/cli.py,sha256=Rj8lsUciI4Rsn5_wfwYDkrD3EsR5FFy2PZzd2qbq3XQ,68206
3
3
  kekkai/config.py,sha256=LE7bKsmv5dim5KnZya0V7_LtviNQ1V0pMN_6FyAsMpc,13084
4
4
  kekkai/dojo.py,sha256=erLdTMOioTyzVhXYW8xgdbU5Ro-KQx1OcTQN7_zemmY,18634
5
5
  kekkai/dojo_import.py,sha256=D0ZQM_0JYHqUqJA3l4nKD-RkpvcOcgj-4zv59HRcQ6k,7274
6
6
  kekkai/manifest.py,sha256=Ph5xGDKuVxMW1GVIisRhxUelaiVZQe-W5sZWsq4lHqs,1887
7
- kekkai/output.py,sha256=QdIFsXCRb9aT5PaKmj0j0y2fg3IRjcLXQMzuGlASNFY,5426
7
+ kekkai/output.py,sha256=V8GkFgaT8oYQOxplBeSXv60q6BeBICTfrrLE-5qDuIc,5426
8
8
  kekkai/paths.py,sha256=EcyG3CEOQFQygowu7O5Mp85dKkXWWvnm1h0j_BetGxY,1190
9
9
  kekkai/policy.py,sha256=0XCUH-SbnO1PsM-exjSFHYHRnLkiNa50QfkyPakwNko,9792
10
10
  kekkai/runner.py,sha256=MBFUiJ4sSVEGNbJ6cv-8p1WHaHqjio6yWEfr_K4GuTs,2037
@@ -58,14 +58,15 @@ kekkai/threatflow/model_adapter.py,sha256=Vl0wBWvBUxEGTmFghjwpp-N7Zt3qkpUSxrPVjK
58
58
  kekkai/threatflow/prompts.py,sha256=lgbj7FJ1c3UYj4ofGnlLoRmywYBfdAKY0QEHmIB_JFw,8525
59
59
  kekkai/threatflow/redaction.py,sha256=mGUcNQB6YPVKArtMrEYcXCWslgUiCkloiowY9IlZ1iY,7622
60
60
  kekkai/threatflow/sanitizer.py,sha256=uQsxYZ5VDXutZoj-WMl7fo5T07uHuQZqgVzoVMoaKec,22688
61
- kekkai/triage/__init__.py,sha256=gYf4XPIYZTthU0Q0kaptbgMKulkjLxWQWG0HQvtlu-o,2182
62
- kekkai/triage/app.py,sha256=MU2tBI50d8sOdDKESGNrWYiREG9bBtrSccaMoiMv5gM,5027
61
+ kekkai/triage/__init__.py,sha256=lUDFJqQk0EjQD2ZP6s3WQnZ8R-KGds8_W7RmFzBDPN8,2463
62
+ kekkai/triage/app.py,sha256=WKvT00UDO2ywVgahLlHAcqB-OnPN05Ps_FKoN6RY0lc,5615
63
63
  kekkai/triage/audit.py,sha256=UVaSKKC6tZkHxEoMcnIZkMOT_ngj7QzHWYuDAHas_sc,5842
64
- kekkai/triage/fix_screen.py,sha256=mj_waXwKPCrT01bVSSu5Ohi-3JvN2lT18Yy44xICItY,7667
64
+ kekkai/triage/code_context.py,sha256=UyPtvUwdfZLuJjDpzmusYkLzs27GyiInTer95wrYg94,10111
65
+ kekkai/triage/fix_screen.py,sha256=jL0ZXoNKBvkhrnWxutPrviMRL5iacJ19qpxX3hKEZvc,8843
65
66
  kekkai/triage/ignore.py,sha256=uBKM7zKyzORj9LJ5AAnoYWZQTRy57P0ZofSapiDWcfI,7305
66
67
  kekkai/triage/loader.py,sha256=vywhS8fcre7PiBX3H2CpKXFxzvO7LcDnIHIB0kzG3R4,5850
67
68
  kekkai/triage/models.py,sha256=nRmWtELMqHWHX1NqZ2upH2ZAJVeBxa3Wh8f3kkB9WYo,5384
68
- kekkai/triage/screens.py,sha256=MbudQkdQ4JFt5c80V3LtqCeXxAIu7nIfZpm7G5wRXT0,11061
69
+ kekkai/triage/screens.py,sha256=XeQUiTagNXL_CdH_oqtDnPOdusteRiNBMRBnXeoypK0,19891
69
70
  kekkai/triage/widgets.py,sha256=eOF6Qoo5uBqjxiEkbpgcO1tbIOGBQBKn75wP9Jw_AaE,4733
70
71
  kekkai_core/__init__.py,sha256=gREN4oarM0azTkSTWTnlDnPZGgv1msai2Deq9Frj3gc,122
71
72
  kekkai_core/redaction.py,sha256=EeWYPjAs2hIXlLKGmGn_PRdK08G4KcOBmbRCoFklbHc,2893
@@ -85,8 +86,8 @@ kekkai_core/windows/chocolatey.py,sha256=tF5S5eN-HeENRt6yQ4TZgwng0oRMX_ScskQ3-eb
85
86
  kekkai_core/windows/installer.py,sha256=MePAywHH3JTIAENv52XtkUMOGqmYqZqkH77VW5PST8o,6945
86
87
  kekkai_core/windows/scoop.py,sha256=lvothICrAoB3lGfkvhqVeNTB50eMmVGA0BE7JNCfHdI,5284
87
88
  kekkai_core/windows/validators.py,sha256=45xUuAbHcKc0WLIZ-0rByPeDD88MAV8KvopngyYBHpQ,6525
88
- kekkai_cli-2.0.0.dist-info/METADATA,sha256=FiwQoECQj5ks2uQEiZ3oMkOSlkf7QE0d9ke-UNRnbPw,8125
89
- kekkai_cli-2.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
90
- kekkai_cli-2.0.0.dist-info/entry_points.txt,sha256=MBV1OIfxJmT2oJvzeeFKIH1eh8M9kKAn7JqFBeuMfWA,43
91
- kekkai_cli-2.0.0.dist-info/top_level.txt,sha256=wWwh7GGPaUjcaCRmt70ueL3WQoQbeGa5L0T0hgOh-MY,19
92
- kekkai_cli-2.0.0.dist-info/RECORD,,
89
+ kekkai_cli-2.2.0.dist-info/METADATA,sha256=EreFgZdr64N1_IITenfMRgnQCDBtQ6Fabvye96QT3g8,7865
90
+ kekkai_cli-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
91
+ kekkai_cli-2.2.0.dist-info/entry_points.txt,sha256=MBV1OIfxJmT2oJvzeeFKIH1eh8M9kKAn7JqFBeuMfWA,43
92
+ kekkai_cli-2.2.0.dist-info/top_level.txt,sha256=wWwh7GGPaUjcaCRmt70ueL3WQoQbeGa5L0T0hgOh-MY,19
93
+ kekkai_cli-2.2.0.dist-info/RECORD,,