kekkai-cli 2.0.1__py3-none-any.whl → 2.2.1__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,39 @@ 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 (with warning)
1272
+ if repo_path is None:
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")
1277
+
1278
+ return run_triage(
1279
+ findings=findings,
1280
+ output_path=output_path,
1281
+ repo_path=repo_path,
1282
+ context_lines=context_lines,
1283
+ )
1236
1284
 
1237
1285
 
1238
1286
  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.1"
60
+ VERSION = "2.2.1"
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,369 @@
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 and encoding fallback)
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
+ # Try UTF-8 first (strict mode)
220
+ file_content = full_path.read_text(encoding="utf-8")
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.)
245
+ logger.warning(
246
+ "code_context_read_error",
247
+ extra={"file_path": Path(file_path).name, "error": str(e)},
248
+ )
249
+ return CodeContext(
250
+ code="",
251
+ language="",
252
+ vulnerable_line="",
253
+ error="Cannot read file",
254
+ )
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
+
264
+ # Extract code context using existing logic from fix engine
265
+ code_context, vulnerable_line = self._prompt_builder.extract_code_context(
266
+ file_content, line
267
+ )
268
+
269
+ # Detect language for syntax highlighting
270
+ language = self._detect_language(full_path)
271
+
272
+ return CodeContext(
273
+ code=code_context,
274
+ language=language,
275
+ vulnerable_line=vulnerable_line,
276
+ error=None,
277
+ )
278
+
279
+ def _validate_path(self, path: Path) -> bool:
280
+ """Validate that path is within repo_path (prevent traversal).
281
+
282
+ Args:
283
+ path: Resolved path to validate.
284
+
285
+ Returns:
286
+ True if path is safe, False otherwise.
287
+
288
+ Security:
289
+ ASVS V5.3.3: Path validation to prevent directory traversal.
290
+ """
291
+ try:
292
+ # Check if path is within repo_path
293
+ path.relative_to(self.repo_path)
294
+ return True
295
+ except ValueError:
296
+ return False
297
+
298
+ def _is_text_file(self, path: Path) -> bool:
299
+ """Check if file is a text file (not binary).
300
+
301
+ Args:
302
+ path: Path to check.
303
+
304
+ Returns:
305
+ True if likely a text file, False if binary.
306
+ """
307
+ suffix = path.suffix.lower()
308
+ name = path.name.lower()
309
+
310
+ # Check against binary extensions
311
+ if suffix in BINARY_EXTENSIONS:
312
+ return False
313
+
314
+ # Special cases without extensions
315
+ if name in ("dockerfile", "makefile", "vagrantfile", "jenkinsfile"):
316
+ return True
317
+
318
+ return True
319
+
320
+ def _is_sensitive_file(self, path: Path) -> bool:
321
+ """Check if file contains sensitive data (secrets, keys).
322
+
323
+ Args:
324
+ path: Path to check.
325
+
326
+ Returns:
327
+ True if file is sensitive and should not be displayed.
328
+
329
+ Security:
330
+ ASVS V8.3.4: Prevent sensitive data in outputs.
331
+ """
332
+ suffix = path.suffix.lower()
333
+ name = path.name.lower()
334
+
335
+ # Check extension
336
+ if suffix in SENSITIVE_EXTENSIONS:
337
+ return True
338
+
339
+ # Check if the entire filename (including leading dot) matches sensitive patterns
340
+ # For files like .env, .pem, etc., the suffix is empty but name includes the dot
341
+ if name in {".env", ".pem", ".key", ".crt"}:
342
+ return True
343
+
344
+ # Check filename patterns
345
+ return any(
346
+ pattern in name
347
+ for pattern in [
348
+ "secret",
349
+ "credential",
350
+ "password",
351
+ "token",
352
+ "apikey",
353
+ "private_key",
354
+ "id_rsa",
355
+ "id_dsa",
356
+ "id_ecdsa",
357
+ ]
358
+ )
359
+
360
+ def _detect_language(self, path: Path) -> str:
361
+ """Detect programming language from file extension.
362
+
363
+ Args:
364
+ path: Path to the file.
365
+
366
+ Returns:
367
+ Language identifier for syntax highlighting.
368
+ """
369
+ return self._prompt_builder._detect_language(str(path))
@@ -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)]
@@ -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,201 @@ 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
+ from .editor_support import build_editor_command, detect_editor_config, validate_editor_name
440
+
441
+ logger = logging.getLogger(__name__)
442
+
443
+ if not self.finding.file_path or not self.finding.line:
444
+ self.notify(
445
+ "Cannot open editor: no file path or line number",
446
+ severity="warning",
447
+ )
448
+ return
449
+
450
+ # Get editor from environment (ASVS V5.1.3: validate before use)
451
+ editor = os.environ.get("EDITOR", "vim")
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
+
462
+ # Security validation: check editor exists and is executable
463
+ editor_path = shutil.which(editor)
464
+ if not editor_path:
465
+ self.notify(
466
+ f"Editor '{editor}' not found. Set $EDITOR environment variable.",
467
+ severity="error",
468
+ )
469
+ return
470
+
471
+ # Construct file path relative to repo
472
+ file_path = self.repo_path / self.finding.file_path
473
+ if not file_path.exists():
474
+ self.notify(f"File not found: {self.finding.file_path}", severity="error")
475
+ return
476
+
477
+ # Detect editor type and build appropriate command
478
+ editor_config = detect_editor_config(editor)
479
+
480
+ # Log editor invocation (ASVS V16.7.1)
481
+ logger.info(
482
+ "editor_opened",
483
+ extra={
484
+ "editor": editor,
485
+ "editor_type": editor_config.syntax_type,
486
+ "file": self.finding.file_path,
487
+ "line": self.finding.line,
488
+ },
489
+ )
490
+
491
+ # ASVS V14.2.1: Use list args (not shell=True) to prevent injection
492
+ cmd = build_editor_command(editor_path, file_path, self.finding.line, editor_config)
493
+
494
+ try:
495
+ # Suspend TUI, run editor, then resume
496
+ with self.app.suspend():
497
+ # S603: subprocess with validated input (editor_path checked via shutil.which)
498
+ result = subprocess.run(cmd, check=False) # noqa: S603
499
+ if result.returncode != 0:
500
+ self.notify(
501
+ f"Editor exited with code {result.returncode}",
502
+ severity="warning",
503
+ )
504
+ except Exception as e:
505
+ logger.error("editor_failed", extra={"error": str(e)})
506
+ self.notify(f"Failed to open editor: {e}", severity="error")
507
+
508
+ def action_expand_context(self) -> None:
509
+ """Expand code context by 10 lines (addresses keyhole effect)."""
510
+ self.context_lines = min(100, self.context_lines + 10)
511
+ self._refresh_code_context()
512
+ self.notify(f"Context expanded to {self.context_lines} lines", severity="information")
513
+
514
+ def action_shrink_context(self) -> None:
515
+ """Shrink code context by 10 lines."""
516
+ self.context_lines = max(5, self.context_lines - 10)
517
+ self._refresh_code_context()
518
+ self.notify(f"Context shrunk to {self.context_lines} lines", severity="information")
519
+
520
+ def _refresh_code_context(self) -> None:
521
+ """Refresh code context display with new context_lines setting."""
522
+ import logging
523
+
524
+ logger = logging.getLogger(__name__)
525
+
526
+ try:
527
+ # Update the prompt builder's context lines
528
+ self._code_extractor._prompt_builder.context_lines = self.context_lines
529
+
530
+ # Re-render code context
531
+ code_widget = self._render_code_context()
532
+ if code_widget:
533
+ # Find and update the existing widget
534
+ try:
535
+ old_widget = self.query_one("#code-context-display")
536
+ # Replace content by removing old and adding new
537
+ old_widget.remove()
538
+ # Find the label and insert after it
539
+ container = self.query_one("#detail-container")
540
+ container.mount(code_widget)
541
+ except Exception as e:
542
+ # Widget not found or couldn't refresh
543
+ logger.debug("code_context_refresh_failed", extra={"error": str(e)})
544
+ except Exception as e:
545
+ # Refresh failed, log but don't crash
546
+ logger.debug("code_context_update_failed", extra={"error": str(e)})
547
+
548
+ def _render_code_context(self) -> Static | None:
549
+ """Render code context with syntax highlighting.
550
+
551
+ Returns:
552
+ Static widget with Syntax-highlighted code, or None if unavailable.
553
+
554
+ Security:
555
+ - File size limit: 10MB (ASVS V10.3.3)
556
+ - Path validation: must be within repo_path (ASVS V5.3.3)
557
+ - Error sanitization: no full paths in errors (ASVS V7.4.1)
558
+ - Sensitive file blocking (ASVS V8.3.4)
559
+ """
560
+ if not self.finding.file_path or not self.finding.line:
561
+ return None
562
+
563
+ context = self._code_extractor.extract(self.finding.file_path, self.finding.line)
564
+ if not context:
565
+ return None
566
+
567
+ if context.error:
568
+ return Static(f"Code unavailable: {context.error}", classes="error-message")
569
+
570
+ # Create syntax-highlighted code display
571
+ try:
572
+ syntax = Syntax(
573
+ context.code,
574
+ context.language,
575
+ line_numbers=False, # We already have line numbers in the context
576
+ theme="monokai",
577
+ word_wrap=False,
578
+ )
579
+ return Static(syntax, id="code-context-display")
580
+ except Exception:
581
+ # Fallback to plain text if syntax highlighting fails
582
+ 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.1
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
@@ -14,19 +14,20 @@ Requires-Dist: jinja2>=3.1.6
14
14
  <img src="https://raw.githubusercontent.com/kademoslabs/assets/main/logos/kekkai-slim.png" alt="Kekkai CLI Logo" width="250"/>
15
15
  </p>
16
16
 
17
- <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>
18
18
 
19
19
  <p align="center">
20
20
  <img src="https://img.shields.io/github/actions/workflow/status/kademoslabs/kekkai/docker-publish.yml?logo=github"/>
21
21
  <img src="https://img.shields.io/circleci/build/github/kademoslabs/kekkai?logo=circleci"/>
22
- <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
+
23
24
  </p>
24
25
 
25
26
  ---
26
27
 
27
28
  # Kekkai
28
29
 
29
- **Interactive security triage in the terminal.**
30
+ **Stop parsing JSON.**
30
31
 
31
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.
32
33
 
@@ -73,6 +74,17 @@ kekkai triage
73
74
  # Interactive TUI to review findings with keyboard navigation
74
75
  ```
75
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
+
76
88
  No signup, no cloud service required.
77
89
 
78
90
  ---
@@ -114,34 +126,11 @@ kekkai triage
114
126
 
115
127
  ---
116
128
 
117
- ### CI/CD Policy Gate
118
-
119
- Break builds on severity thresholds.
120
-
121
- Kekkai can be used as a CI gate based on severity thresholds.
129
+ ### 🚦 CI/CD in 1 Second
122
130
 
131
+ Don't write YAML. Run this in your repo:
123
132
  ```bash
124
- # Fail on any critical or high findings
125
- kekkai scan --ci --fail-on high
126
-
127
- # Fail only on critical
128
- kekkai scan --ci --fail-on critical
129
- ```
130
-
131
- **Exit Codes:**
132
- | Code | Meaning |
133
- |------|---------|
134
- | 0 | No findings above threshold |
135
- | 1 | Findings exceed threshold |
136
- | 2 | Scanner error |
137
-
138
- **GitHub Actions Example:**
139
-
140
- ```yaml
141
- - name: Security Scan
142
- run: |
143
- pipx install kekkai-cli
144
- kekkai scan --ci --fail-on high
133
+ kekkai init --ci
145
134
  ```
146
135
 
147
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=Mua85FZFkpEmPtP3IUFh1sKkBsQLllln_Qe-ju5okgw,68479
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=IJrO36C79WTxCqYp9LEVn5OI4GtPVBWUWH6I-TttfmQ,5426
7
+ kekkai/output.py,sha256=1T077PHznM8yfDWycfihvGTGrrpymoAkaHlMr8hAjkk,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,16 @@ 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=kmAkQaxgGIxzf2n5s9vkwOBetmuZrQhoDuygxmU9cjg,11311
65
+ kekkai/triage/editor_support.py,sha256=IpOe4KXUdxKBAQMoCWGTNRK2sGYRpuVySiHQRLm4ugc,5497
66
+ kekkai/triage/fix_screen.py,sha256=jL0ZXoNKBvkhrnWxutPrviMRL5iacJ19qpxX3hKEZvc,8843
65
67
  kekkai/triage/ignore.py,sha256=uBKM7zKyzORj9LJ5AAnoYWZQTRy57P0ZofSapiDWcfI,7305
66
68
  kekkai/triage/loader.py,sha256=vywhS8fcre7PiBX3H2CpKXFxzvO7LcDnIHIB0kzG3R4,5850
67
69
  kekkai/triage/models.py,sha256=nRmWtELMqHWHX1NqZ2upH2ZAJVeBxa3Wh8f3kkB9WYo,5384
68
- kekkai/triage/screens.py,sha256=MbudQkdQ4JFt5c80V3LtqCeXxAIu7nIfZpm7G5wRXT0,11061
70
+ kekkai/triage/screens.py,sha256=it253GNli37S2CkijbVfoxmiobEV3M3-FYdqXhp1tWk,20575
69
71
  kekkai/triage/widgets.py,sha256=eOF6Qoo5uBqjxiEkbpgcO1tbIOGBQBKn75wP9Jw_AaE,4733
70
72
  kekkai_core/__init__.py,sha256=gREN4oarM0azTkSTWTnlDnPZGgv1msai2Deq9Frj3gc,122
71
73
  kekkai_core/redaction.py,sha256=EeWYPjAs2hIXlLKGmGn_PRdK08G4KcOBmbRCoFklbHc,2893
@@ -85,8 +87,8 @@ kekkai_core/windows/chocolatey.py,sha256=tF5S5eN-HeENRt6yQ4TZgwng0oRMX_ScskQ3-eb
85
87
  kekkai_core/windows/installer.py,sha256=MePAywHH3JTIAENv52XtkUMOGqmYqZqkH77VW5PST8o,6945
86
88
  kekkai_core/windows/scoop.py,sha256=lvothICrAoB3lGfkvhqVeNTB50eMmVGA0BE7JNCfHdI,5284
87
89
  kekkai_core/windows/validators.py,sha256=45xUuAbHcKc0WLIZ-0rByPeDD88MAV8KvopngyYBHpQ,6525
88
- kekkai_cli-2.0.1.dist-info/METADATA,sha256=WNYk460xPTeXtMnwDW2Z6zDhHysNhltYOS726u-i2q4,8118
89
- kekkai_cli-2.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
90
- kekkai_cli-2.0.1.dist-info/entry_points.txt,sha256=MBV1OIfxJmT2oJvzeeFKIH1eh8M9kKAn7JqFBeuMfWA,43
91
- kekkai_cli-2.0.1.dist-info/top_level.txt,sha256=wWwh7GGPaUjcaCRmt70ueL3WQoQbeGa5L0T0hgOh-MY,19
92
- kekkai_cli-2.0.1.dist-info/RECORD,,
90
+ kekkai_cli-2.2.1.dist-info/METADATA,sha256=rFe0gsYLeFJPliwHjenTd3jk6VCsB0qlwCkO6OAzxJo,7865
91
+ kekkai_cli-2.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
92
+ kekkai_cli-2.2.1.dist-info/entry_points.txt,sha256=MBV1OIfxJmT2oJvzeeFKIH1eh8M9kKAn7JqFBeuMfWA,43
93
+ kekkai_cli-2.2.1.dist-info/top_level.txt,sha256=wWwh7GGPaUjcaCRmt70ueL3WQoQbeGa5L0T0hgOh-MY,19
94
+ kekkai_cli-2.2.1.dist-info/RECORD,,