agent-audit 0.1.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.
Files changed (37) hide show
  1. agent_audit/__init__.py +3 -0
  2. agent_audit/__main__.py +13 -0
  3. agent_audit/cli/__init__.py +1 -0
  4. agent_audit/cli/commands/__init__.py +1 -0
  5. agent_audit/cli/commands/init.py +44 -0
  6. agent_audit/cli/commands/inspect.py +236 -0
  7. agent_audit/cli/commands/scan.py +329 -0
  8. agent_audit/cli/formatters/__init__.py +1 -0
  9. agent_audit/cli/formatters/json.py +138 -0
  10. agent_audit/cli/formatters/sarif.py +155 -0
  11. agent_audit/cli/formatters/terminal.py +221 -0
  12. agent_audit/cli/main.py +34 -0
  13. agent_audit/config/__init__.py +1 -0
  14. agent_audit/config/ignore.py +477 -0
  15. agent_audit/core_utils/__init__.py +1 -0
  16. agent_audit/models/__init__.py +18 -0
  17. agent_audit/models/finding.py +159 -0
  18. agent_audit/models/risk.py +77 -0
  19. agent_audit/models/tool.py +182 -0
  20. agent_audit/rules/__init__.py +6 -0
  21. agent_audit/rules/engine.py +503 -0
  22. agent_audit/rules/loader.py +160 -0
  23. agent_audit/scanners/__init__.py +5 -0
  24. agent_audit/scanners/base.py +32 -0
  25. agent_audit/scanners/config_scanner.py +390 -0
  26. agent_audit/scanners/mcp_config_scanner.py +321 -0
  27. agent_audit/scanners/mcp_inspector.py +421 -0
  28. agent_audit/scanners/python_scanner.py +544 -0
  29. agent_audit/scanners/secret_scanner.py +521 -0
  30. agent_audit/utils/__init__.py +21 -0
  31. agent_audit/utils/compat.py +98 -0
  32. agent_audit/utils/mcp_client.py +343 -0
  33. agent_audit/version.py +3 -0
  34. agent_audit-0.1.0.dist-info/METADATA +219 -0
  35. agent_audit-0.1.0.dist-info/RECORD +37 -0
  36. agent_audit-0.1.0.dist-info/WHEEL +4 -0
  37. agent_audit-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,521 @@
1
+ """Secret scanner for detecting hardcoded credentials."""
2
+
3
+ import fnmatch
4
+ import re
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import List, Optional, Pattern, Tuple
8
+ from dataclasses import dataclass, field
9
+
10
+ from agent_audit.scanners.base import BaseScanner, ScanResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class SecretMatch:
17
+ """A detected secret."""
18
+ pattern_name: str
19
+ line_number: int
20
+ line_content: str
21
+ matched_text: str
22
+ start_col: int
23
+ end_col: int
24
+ severity: str # critical, high, medium
25
+
26
+
27
+ @dataclass
28
+ class SecretScanResult(ScanResult):
29
+ """Result of secret scanning."""
30
+ secrets: List[SecretMatch] = field(default_factory=list)
31
+
32
+
33
+ class SecretScanner(BaseScanner):
34
+ """
35
+ Regex-based secret detection scanner.
36
+
37
+ Detects:
38
+ - AWS access keys
39
+ - API keys (OpenAI, Anthropic, GitHub, etc.)
40
+ - Generic tokens and passwords
41
+ - Private keys
42
+ """
43
+
44
+ name = "Secret Scanner"
45
+
46
+ # Secret patterns with severity levels
47
+ SECRET_PATTERNS: List[Tuple[Pattern, str, str]] = [
48
+ # AWS
49
+ (re.compile(r'AKIA[0-9A-Z]{16}'), "AWS Access Key ID", "critical"),
50
+ (re.compile(r'(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])'),
51
+ "Potential AWS Secret Key", "high"),
52
+
53
+ # OpenAI
54
+ (re.compile(r'sk-[a-zA-Z0-9]{48,}'), "OpenAI API Key", "critical"),
55
+ (re.compile(r'sk-proj-[a-zA-Z0-9]{48,}'), "OpenAI Project API Key", "critical"),
56
+
57
+ # Anthropic
58
+ (re.compile(r'sk-ant-[a-zA-Z0-9-]{40,}'), "Anthropic API Key", "critical"),
59
+
60
+ # GitHub
61
+ (re.compile(r'ghp_[a-zA-Z0-9]{36}'), "GitHub Personal Access Token", "critical"),
62
+ (re.compile(r'gho_[a-zA-Z0-9]{36}'), "GitHub OAuth Token", "critical"),
63
+ (re.compile(r'ghs_[a-zA-Z0-9]{36}'), "GitHub App Token", "critical"),
64
+ (re.compile(r'ghr_[a-zA-Z0-9]{36}'), "GitHub Refresh Token", "critical"),
65
+
66
+ # Google
67
+ (re.compile(r'AIza[0-9A-Za-z\-_]{35}'), "Google API Key", "critical"),
68
+
69
+ # Stripe
70
+ (re.compile(r'sk_live_[a-zA-Z0-9]{24,}'), "Stripe Live Secret Key", "critical"),
71
+ (re.compile(r'sk_test_[a-zA-Z0-9]{24,}'), "Stripe Test Secret Key", "high"),
72
+ (re.compile(r'pk_live_[a-zA-Z0-9]{24,}'), "Stripe Live Publishable Key", "medium"),
73
+
74
+ # Generic patterns
75
+ (re.compile(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{20,})["\']?'),
76
+ "Generic API Key", "high"),
77
+ (re.compile(r'(?i)(secret|password|passwd|pwd)\s*[=:]\s*["\']?([^\s"\']{8,})["\']?'),
78
+ "Generic Secret/Password", "high"),
79
+ (re.compile(r'(?i)(token|auth[_-]?token)\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{20,})["\']?'),
80
+ "Generic Token", "high"),
81
+
82
+ # Private keys
83
+ (re.compile(r'-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----'),
84
+ "Private Key Header", "critical"),
85
+ (re.compile(r'-----BEGIN PGP PRIVATE KEY BLOCK-----'),
86
+ "PGP Private Key", "critical"),
87
+
88
+ # Database connection strings
89
+ (re.compile(r'(?i)(?:mysql|postgres|postgresql|mongodb|redis)://[^\s"\']+:[^\s"\']+@'),
90
+ "Database Connection String with Credentials", "critical"),
91
+
92
+ # JWT secrets
93
+ (re.compile(r'(?i)jwt[_-]?secret\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{16,})["\']?'),
94
+ "JWT Secret", "high"),
95
+
96
+ # Slack
97
+ (re.compile(r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*'),
98
+ "Slack Token", "critical"),
99
+
100
+ # Twilio
101
+ (re.compile(r'SK[a-f0-9]{32}'), "Twilio API Key", "critical"),
102
+
103
+ # SendGrid
104
+ (re.compile(r'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}'),
105
+ "SendGrid API Key", "critical"),
106
+ ]
107
+
108
+ # File extensions to scan
109
+ SCANNABLE_EXTENSIONS = {
110
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml',
111
+ '.env', '.cfg', '.conf', '.config', '.ini', '.properties',
112
+ '.sh', '.bash', '.zsh', '.toml', '.xml', '.md', '.txt'
113
+ }
114
+
115
+ # Files to always skip
116
+ SKIP_FILES = {
117
+ 'package-lock.json', 'yarn.lock', 'poetry.lock',
118
+ 'Cargo.lock', 'go.sum', 'pnpm-lock.yaml'
119
+ }
120
+
121
+ def __init__(
122
+ self,
123
+ exclude_paths: Optional[List[str]] = None,
124
+ custom_patterns: Optional[List[Tuple[str, str, str]]] = None
125
+ ):
126
+ """
127
+ Initialize the secret scanner.
128
+
129
+ Args:
130
+ exclude_paths: Path patterns to exclude
131
+ custom_patterns: Additional patterns as (regex, name, severity) tuples
132
+ """
133
+ self.exclude_paths = set(exclude_paths or [])
134
+ self.patterns = list(self.SECRET_PATTERNS)
135
+
136
+ # Add custom patterns
137
+ if custom_patterns:
138
+ for regex_str, name, severity in custom_patterns:
139
+ self.patterns.append((re.compile(regex_str), name, severity))
140
+
141
+ def scan(self, path: Path) -> List[SecretScanResult]:
142
+ """
143
+ Scan for secrets in files.
144
+
145
+ Args:
146
+ path: File or directory to scan
147
+
148
+ Returns:
149
+ List of scan results
150
+ """
151
+ results = []
152
+ files = self._find_files(path)
153
+
154
+ for file_path in files:
155
+ result = self._scan_file(file_path)
156
+ if result and result.secrets:
157
+ results.append(result)
158
+
159
+ return results
160
+
161
+ def _find_files(self, path: Path) -> List[Path]:
162
+ """Find files to scan."""
163
+ if path.is_file():
164
+ if self._should_scan_file(path):
165
+ return [path]
166
+ return []
167
+
168
+ files = []
169
+ for file_path in path.rglob('*'):
170
+ if not file_path.is_file():
171
+ continue
172
+
173
+ if not self._should_scan_file(file_path):
174
+ continue
175
+
176
+ # Check exclude patterns using glob matching
177
+ rel_path = str(file_path.relative_to(path))
178
+ if self._should_exclude(rel_path):
179
+ continue
180
+
181
+ files.append(file_path)
182
+
183
+ return files
184
+
185
+ def _should_exclude(self, rel_path: str) -> bool:
186
+ """Check if a relative path matches any exclude pattern."""
187
+ # Normalize path separators
188
+ normalized_path = rel_path.replace('\\', '/')
189
+
190
+ for pattern in self.exclude_paths:
191
+ normalized_pattern = pattern.replace('\\', '/')
192
+
193
+ # Simple substring matching (backward compatibility)
194
+ if normalized_pattern in normalized_path:
195
+ return True
196
+
197
+ # Direct fnmatch for glob patterns
198
+ if fnmatch.fnmatch(normalized_path, normalized_pattern):
199
+ return True
200
+
201
+ # Handle "tests/**" style patterns
202
+ if normalized_pattern.endswith('/**'):
203
+ prefix = normalized_pattern[:-3]
204
+ if normalized_path.startswith(prefix + '/') or normalized_path == prefix:
205
+ return True
206
+
207
+ # Handle "**/test_*" style patterns
208
+ if normalized_pattern.startswith('**/'):
209
+ suffix_pattern = normalized_pattern[3:]
210
+ # Match against filename
211
+ filename = Path(normalized_path).name
212
+ if fnmatch.fnmatch(filename, suffix_pattern):
213
+ return True
214
+ # Match against any path segment
215
+ for part in Path(normalized_path).parts:
216
+ if fnmatch.fnmatch(part, suffix_pattern):
217
+ return True
218
+
219
+ return False
220
+
221
+ def _should_scan_file(self, file_path: Path) -> bool:
222
+ """Check if a file should be scanned."""
223
+ # Skip known non-secret files
224
+ if file_path.name in self.SKIP_FILES:
225
+ return False
226
+
227
+ # Skip hidden directories
228
+ if any(part.startswith('.') and part not in {'.env'}
229
+ for part in file_path.parts[:-1]):
230
+ return False
231
+
232
+ # Skip common non-source directories
233
+ skip_dirs = {'node_modules', 'venv', '.venv', '__pycache__',
234
+ 'dist', 'build', '.git'}
235
+ if any(part in skip_dirs for part in file_path.parts):
236
+ return False
237
+
238
+ # Check extension
239
+ if file_path.suffix.lower() in self.SCANNABLE_EXTENSIONS:
240
+ return True
241
+
242
+ # Also scan .env files regardless of extension
243
+ if '.env' in file_path.name:
244
+ return True
245
+
246
+ return False
247
+
248
+ def _scan_file(self, file_path: Path) -> Optional[SecretScanResult]:
249
+ """Scan a single file for secrets."""
250
+ try:
251
+ content = file_path.read_text(encoding='utf-8', errors='ignore')
252
+ except Exception as e:
253
+ logger.warning(f"Error reading {file_path}: {e}")
254
+ return None
255
+
256
+ secrets = []
257
+ lines = content.splitlines()
258
+
259
+ for line_num, line in enumerate(lines, start=1):
260
+ # Skip empty lines and comments
261
+ stripped = line.strip()
262
+ if not stripped or stripped.startswith('#') or stripped.startswith('//'):
263
+ continue
264
+
265
+ # Check each pattern
266
+ for pattern, name, severity in self.patterns:
267
+ for match in pattern.finditer(line):
268
+ # Filter out false positives
269
+ if self._is_false_positive(line, match, file_path):
270
+ continue
271
+
272
+ secret = SecretMatch(
273
+ pattern_name=name,
274
+ line_number=line_num,
275
+ line_content=self._mask_secret(line, match),
276
+ matched_text=self._mask_match(match.group()),
277
+ start_col=match.start(),
278
+ end_col=match.end(),
279
+ severity=severity
280
+ )
281
+ secrets.append(secret)
282
+
283
+ return SecretScanResult(
284
+ source_file=str(file_path),
285
+ secrets=secrets
286
+ )
287
+
288
+ def _is_false_positive(
289
+ self,
290
+ line: str,
291
+ match: re.Match,
292
+ file_path: Path
293
+ ) -> bool:
294
+ """
295
+ Check if a match is likely a false positive.
296
+
297
+ Filters out:
298
+ - Example/placeholder values
299
+ - Test fixtures
300
+ - Documentation
301
+ - Variable/class/function names containing keywords
302
+ - Environment variable lookups
303
+ - Secure wrappers (SecretStr, etc.)
304
+ """
305
+ matched_text = match.group().lower()
306
+ line_lower = line.lower()
307
+ stripped = line.strip()
308
+
309
+ # Common placeholder patterns
310
+ placeholders = [
311
+ 'example', 'placeholder', 'your_', 'my_', 'xxx',
312
+ 'test', 'fake', 'dummy', 'sample', 'demo', '<your',
313
+ 'insert_', 'replace_', 'changeme', 'undefined'
314
+ ]
315
+ if any(p in matched_text for p in placeholders):
316
+ return True
317
+
318
+ # Check if this looks like documentation
319
+ if '# example' in line_lower or '// example' in line_lower:
320
+ return True
321
+
322
+ # Check file path for test/example indicators
323
+ path_str = str(file_path).lower()
324
+ if any(p in path_str for p in ['test', 'example', 'fixture', 'mock', 'sample']):
325
+ return True
326
+
327
+ # Skip class definitions (class FooTokenBar:)
328
+ if stripped.startswith('class '):
329
+ return True
330
+
331
+ # Skip function definitions (def get_api_key(...):)
332
+ if stripped.startswith('def '):
333
+ return True
334
+
335
+ # Skip import statements
336
+ if stripped.startswith('import ') or stripped.startswith('from '):
337
+ return True
338
+
339
+ # Skip type annotations (variable: SecretStr)
340
+ if re.search(r':\s*(Optional\[)?SecretStr', line):
341
+ return True
342
+
343
+ # Check for environment variable lookups - value is not hardcoded
344
+ env_patterns = [
345
+ r'os\.environ\.get\s*\(',
346
+ r'os\.environ\[',
347
+ r'os\.getenv\s*\(',
348
+ r'getenv\s*\(',
349
+ r'environ\.get\s*\(',
350
+ r'settings\.\w+', # e.g., settings.API_KEY
351
+ r'config\.\w+', # e.g., config.api_key
352
+ r'Config\.\w+',
353
+ r'get_from_\w+\s*\(', # get_from_env, get_from_dict_or_env, etc.
354
+ ]
355
+ for pattern in env_patterns:
356
+ if re.search(pattern, line):
357
+ return True
358
+
359
+ # Check for secure wrappers - value is wrapped, not exposed
360
+ secure_wrappers = [
361
+ r'SecretStr\s*\(',
362
+ r'Secret\s*\(',
363
+ r'SecureString\s*\(',
364
+ r'Field\s*\([^)]*secret\s*=\s*True',
365
+ ]
366
+ for pattern in secure_wrappers:
367
+ if re.search(pattern, line, re.IGNORECASE):
368
+ return True
369
+
370
+ # For generic patterns (api_key=, token=, etc.), verify the right side
371
+ # is a string literal that looks like a real secret, not a variable
372
+ if self._is_generic_pattern_match(match):
373
+ if not self._has_literal_secret_value(line, match):
374
+ return True
375
+
376
+ # Check if match looks like a PascalCase class/type name (e.g., ConversationTokenBufferMemory)
377
+ matched_text_raw = match.group()
378
+ if self._looks_like_class_name(matched_text_raw, line):
379
+ return True
380
+
381
+ # Environment variable references (not actual values)
382
+ if '${' in line or '$(' in line:
383
+ if matched_text in line[match.start():match.end()+5]:
384
+ # Check if the match is inside a variable reference
385
+ before = line[:match.start()]
386
+ if '${' in before[-10:] or '$(' in before[-10:]:
387
+ return True
388
+
389
+ return False
390
+
391
+ def _looks_like_class_name(self, matched_text: str, line: str) -> bool:
392
+ """
393
+ Check if the matched text looks like a class/type name rather than a secret.
394
+
395
+ PascalCase identifiers with multiple capital letters are likely class names,
396
+ not secrets. Real secrets don't follow PascalCase naming conventions.
397
+ """
398
+ # Check for PascalCase pattern: starts with capital, has multiple capitals
399
+ # Also allow all-alpha strings that follow PascalCase (no numbers, no special chars)
400
+ text_to_check = matched_text
401
+
402
+ # If match contains '=' (from AWS pattern [A-Za-z0-9/+=]), extract the part after '='
403
+ # This handles cases like 'factory=PairwiseStringResultOutputParser'
404
+ if '=' in matched_text:
405
+ parts = matched_text.split('=')
406
+ # Check if the part after = looks like a class name
407
+ text_to_check = parts[-1]
408
+
409
+ if re.match(r'^[A-Z][a-zA-Z]+$', text_to_check):
410
+ # Count capital letters - class names typically have several
411
+ capital_count = sum(1 for c in text_to_check if c.isupper())
412
+ if capital_count >= 2:
413
+ return True
414
+
415
+ # Check for common class name suffixes
416
+ class_suffixes = [
417
+ 'Memory', 'Buffer', 'Parser', 'Handler', 'Manager',
418
+ 'Factory', 'Builder', 'Wrapper', 'Provider', 'Service',
419
+ 'Client', 'Server', 'Controller', 'Processor', 'Validator'
420
+ ]
421
+ if any(text_to_check.endswith(suffix) for suffix in class_suffixes):
422
+ return True
423
+
424
+ return False
425
+
426
+ def _is_generic_pattern_match(self, match: re.Match) -> bool:
427
+ """Check if this match is from a generic pattern (api_key=, token=, etc.)."""
428
+ pattern_str = match.re.pattern
429
+ # Generic patterns have the keyword group followed by = or :
430
+ generic_indicators = [
431
+ r'\(api[_-]?key|apikey\)',
432
+ r'\(secret|password|passwd|pwd\)',
433
+ r'\(token|auth[_-]?token\)',
434
+ r'jwt[_-]?secret',
435
+ ]
436
+ for indicator in generic_indicators:
437
+ if indicator in pattern_str.lower():
438
+ return True
439
+ return False
440
+
441
+ def _has_literal_secret_value(self, line: str, match: re.Match) -> bool:
442
+ """
443
+ Check if the matched assignment has a string literal value that looks like a secret.
444
+
445
+ Returns True if it looks like a real hardcoded secret, False if it's likely
446
+ a variable reference, function call, or non-secret value.
447
+ """
448
+ # Extract the part after the = or :
449
+ match_text = match.group()
450
+ eq_pos = -1
451
+ for sep in ['=', ':']:
452
+ pos = match_text.find(sep)
453
+ if pos != -1:
454
+ eq_pos = pos
455
+ break
456
+
457
+ if eq_pos == -1:
458
+ return True # Not an assignment pattern, let other checks handle it
459
+
460
+ # Get the value part (after = or :)
461
+ value_part = match_text[eq_pos + 1:].strip()
462
+
463
+ # Remove leading quotes
464
+ if value_part.startswith('"') or value_part.startswith("'"):
465
+ value_part = value_part[1:]
466
+ if value_part.endswith('"') or value_part.endswith("'"):
467
+ value_part = value_part[:-1]
468
+
469
+ # Check if value is empty or too short
470
+ if len(value_part) < 8:
471
+ return False
472
+
473
+ # Check if value looks like a variable name (all lowercase/uppercase, underscores)
474
+ if re.match(r'^[a-z_][a-z0-9_]*$', value_part) and not any(c.isdigit() for c in value_part[-4:]):
475
+ return False
476
+
477
+ # Check if it's a common non-secret pattern
478
+ non_secret_patterns = [
479
+ r'^[A-Z_]+$', # All caps constant name like API_KEY
480
+ r'^None$',
481
+ r'^null$',
482
+ r'^""$',
483
+ r"^''$",
484
+ r'^\.\.\.$', # Ellipsis
485
+ ]
486
+ for pattern in non_secret_patterns:
487
+ if re.match(pattern, value_part, re.IGNORECASE):
488
+ return False
489
+
490
+ # Check for mixed characters (letters, numbers, special chars) typical of secrets
491
+ has_letters = bool(re.search(r'[a-zA-Z]', value_part))
492
+ has_numbers = bool(re.search(r'[0-9]', value_part))
493
+ has_special = bool(re.search(r'[-_/+=]', value_part))
494
+
495
+ # Real secrets typically have a mix of character types
496
+ char_types = sum([has_letters, has_numbers, has_special])
497
+ if char_types < 2 and len(value_part) < 20:
498
+ return False
499
+
500
+ return True
501
+
502
+ def _mask_secret(self, line: str, match: re.Match) -> str:
503
+ """Mask the secret value in a line for safe display."""
504
+ start = match.start()
505
+ end = match.end()
506
+ matched_len = end - start
507
+
508
+ if matched_len <= 8:
509
+ masked = '*' * matched_len
510
+ else:
511
+ # Show first and last 4 chars
512
+ original = match.group()
513
+ masked = original[:4] + '*' * (matched_len - 8) + original[-4:]
514
+
515
+ return line[:start] + masked + line[end:]
516
+
517
+ def _mask_match(self, text: str) -> str:
518
+ """Mask a matched secret for display."""
519
+ if len(text) <= 8:
520
+ return '*' * len(text)
521
+ return text[:4] + '*' * (len(text) - 8) + text[-4:]
@@ -0,0 +1,21 @@
1
+ """Utilities for agent-audit."""
2
+
3
+ from agent_audit.utils.compat import (
4
+ IS_WINDOWS,
5
+ IS_MACOS,
6
+ IS_LINUX,
7
+ normalize_path,
8
+ home_config_dir,
9
+ get_subprocess_creation_flags,
10
+ setup_event_loop_policy,
11
+ )
12
+
13
+ __all__ = [
14
+ "IS_WINDOWS",
15
+ "IS_MACOS",
16
+ "IS_LINUX",
17
+ "normalize_path",
18
+ "home_config_dir",
19
+ "get_subprocess_creation_flags",
20
+ "setup_event_loop_policy",
21
+ ]
@@ -0,0 +1,98 @@
1
+ """Cross-platform compatibility utilities.
2
+
3
+ This module provides platform detection and path handling utilities
4
+ to ensure the tool works correctly on Windows, macOS, and Linux.
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Union
10
+
11
+ # Platform detection constants
12
+ IS_WINDOWS = sys.platform == "win32"
13
+ IS_MACOS = sys.platform == "darwin"
14
+ IS_LINUX = sys.platform.startswith("linux")
15
+
16
+
17
+ def normalize_path(path: Union[str, Path]) -> str:
18
+ """
19
+ Normalize a path to use forward slashes consistently.
20
+
21
+ This ensures that file paths stored in findings and reports
22
+ use consistent forward slashes across all platforms, making
23
+ output deterministic and comparable.
24
+
25
+ Args:
26
+ path: Path to normalize (string or Path object)
27
+
28
+ Returns:
29
+ Normalized path string with forward slashes
30
+ """
31
+ path_str = str(path)
32
+ # Always use forward slashes for consistency in outputs
33
+ return path_str.replace("\\", "/")
34
+
35
+
36
+ def home_config_dir() -> Path:
37
+ """
38
+ Get the appropriate configuration directory for the current platform.
39
+
40
+ Returns:
41
+ - Windows: %APPDATA%/agent-audit
42
+ - macOS: ~/Library/Application Support/agent-audit
43
+ - Linux: ~/.config/agent-audit
44
+
45
+ Falls back to ~/.agent-audit if the platform-specific directory
46
+ cannot be determined.
47
+ """
48
+ if IS_WINDOWS:
49
+ # Use APPDATA on Windows
50
+ import os
51
+ appdata = os.environ.get("APPDATA")
52
+ if appdata:
53
+ return Path(appdata) / "agent-audit"
54
+ # Fallback to home directory
55
+ return Path.home() / ".agent-audit"
56
+
57
+ elif IS_MACOS:
58
+ # Use Library/Application Support on macOS
59
+ return Path.home() / "Library" / "Application Support" / "agent-audit"
60
+
61
+ else:
62
+ # Use XDG_CONFIG_HOME on Linux, fallback to ~/.config
63
+ import os
64
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
65
+ if xdg_config:
66
+ return Path(xdg_config) / "agent-audit"
67
+ return Path.home() / ".config" / "agent-audit"
68
+
69
+
70
+ def get_subprocess_creation_flags() -> int:
71
+ """
72
+ Get the appropriate subprocess creation flags for the current platform.
73
+
74
+ On Windows, returns CREATE_NO_WINDOW to prevent console windows from
75
+ appearing when running background processes. On other platforms, returns 0.
76
+
77
+ Returns:
78
+ Creation flags for subprocess.Popen or asyncio.create_subprocess_exec
79
+ """
80
+ if IS_WINDOWS:
81
+ # CREATE_NO_WINDOW = 0x08000000
82
+ # Prevents console window from appearing
83
+ return 0x08000000
84
+ return 0
85
+
86
+
87
+ def setup_event_loop_policy():
88
+ """
89
+ Configure the asyncio event loop policy for the current platform.
90
+
91
+ On Windows, sets WindowsSelectorEventLoopPolicy to avoid issues with
92
+ ProactorEventLoop and subprocesses. This should be called early in
93
+ the application startup.
94
+ """
95
+ if IS_WINDOWS:
96
+ import asyncio
97
+ # WindowsSelectorEventLoopPolicy is more compatible with subprocess operations
98
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())