empathy-framework 4.8.0__py3-none-any.whl → 4.9.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.
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/METADATA +64 -25
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/RECORD +28 -39
- empathy_os/__init__.py +2 -2
- empathy_os/cache/hash_only.py +3 -6
- empathy_os/cache/hybrid.py +3 -6
- empathy_os/cli_legacy.py +1 -27
- empathy_os/cli_unified.py +0 -25
- empathy_os/memory/__init__.py +5 -19
- empathy_os/memory/short_term.py +132 -10
- empathy_os/memory/types.py +4 -0
- empathy_os/models/registry.py +4 -4
- empathy_os/project_index/scanner.py +3 -2
- empathy_os/socratic/ab_testing.py +1 -1
- empathy_os/workflow_commands.py +9 -9
- empathy_os/workflows/__init__.py +4 -4
- empathy_os/workflows/base.py +8 -54
- empathy_os/workflows/bug_predict.py +2 -2
- empathy_os/workflows/history.py +5 -3
- empathy_os/workflows/perf_audit.py +4 -4
- empathy_os/workflows/progress.py +22 -324
- empathy_os/workflows/routing.py +0 -5
- empathy_os/workflows/security_audit.py +0 -189
- empathy_os/workflows/security_audit_phase3.py +26 -2
- empathy_os/workflows/test_gen.py +7 -7
- empathy_os/vscode_bridge 2.py +0 -173
- empathy_os/workflows/output.py +0 -410
- empathy_os/workflows/progressive/README 2.md +0 -454
- empathy_os/workflows/progressive/__init__ 2.py +0 -92
- empathy_os/workflows/progressive/cli 2.py +0 -242
- empathy_os/workflows/progressive/core 2.py +0 -488
- empathy_os/workflows/progressive/orchestrator 2.py +0 -701
- empathy_os/workflows/progressive/reports 2.py +0 -528
- empathy_os/workflows/progressive/telemetry 2.py +0 -280
- empathy_os/workflows/progressive/test_gen 2.py +0 -514
- empathy_os/workflows/progressive/workflow 2.py +0 -628
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/WHEEL +0 -0
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/entry_points.txt +0 -0
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/licenses/LICENSE +0 -0
- {empathy_framework-4.8.0.dist-info → empathy_framework-4.9.1.dist-info}/top_level.txt +0 -0
empathy_os/workflows/progress.py
CHANGED
|
@@ -11,35 +11,12 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import json
|
|
14
|
-
import
|
|
15
|
-
import sys
|
|
16
|
-
import uuid
|
|
17
|
-
from collections.abc import Callable, Coroutine, Generator
|
|
18
|
-
from contextlib import contextmanager
|
|
14
|
+
from collections.abc import Callable, Coroutine
|
|
19
15
|
from dataclasses import dataclass, field
|
|
20
16
|
from datetime import datetime
|
|
21
17
|
from enum import Enum
|
|
22
18
|
from typing import Any, Protocol
|
|
23
19
|
|
|
24
|
-
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
26
|
-
# Rich imports with fallback
|
|
27
|
-
try:
|
|
28
|
-
from rich.console import Console, Group
|
|
29
|
-
from rich.live import Live
|
|
30
|
-
from rich.panel import Panel
|
|
31
|
-
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn
|
|
32
|
-
from rich.table import Table
|
|
33
|
-
from rich.text import Text
|
|
34
|
-
|
|
35
|
-
RICH_AVAILABLE = True
|
|
36
|
-
except ImportError:
|
|
37
|
-
RICH_AVAILABLE = False
|
|
38
|
-
Console = None # type: ignore
|
|
39
|
-
Live = None # type: ignore
|
|
40
|
-
Panel = None # type: ignore
|
|
41
|
-
Progress = None # type: ignore
|
|
42
|
-
|
|
43
20
|
|
|
44
21
|
class ProgressStatus(Enum):
|
|
45
22
|
"""Status of a workflow or stage."""
|
|
@@ -384,9 +361,9 @@ class ProgressTracker:
|
|
|
384
361
|
for callback in self._callbacks:
|
|
385
362
|
try:
|
|
386
363
|
callback(update)
|
|
387
|
-
except Exception
|
|
388
|
-
#
|
|
389
|
-
|
|
364
|
+
except Exception as e:
|
|
365
|
+
# Log but don't fail on callback errors
|
|
366
|
+
print(f"Progress callback error: {e}")
|
|
390
367
|
|
|
391
368
|
# Call async callbacks
|
|
392
369
|
for async_callback in self._async_callbacks:
|
|
@@ -410,111 +387,39 @@ class ProgressReporter(Protocol):
|
|
|
410
387
|
|
|
411
388
|
|
|
412
389
|
class ConsoleProgressReporter:
|
|
413
|
-
"""
|
|
390
|
+
"""Simple console-based progress reporter for CLI usage."""
|
|
414
391
|
|
|
415
|
-
|
|
416
|
-
- VSCode integrated terminal
|
|
417
|
-
- VSCode output panel
|
|
418
|
-
- IDE debug consoles
|
|
419
|
-
- Standard terminals
|
|
420
|
-
|
|
421
|
-
Uses Unicode symbols that render correctly in most environments.
|
|
422
|
-
"""
|
|
423
|
-
|
|
424
|
-
def __init__(self, verbose: bool = False, show_tokens: bool = False):
|
|
425
|
-
"""Initialize console progress reporter.
|
|
426
|
-
|
|
427
|
-
Args:
|
|
428
|
-
verbose: Show additional details (fallback info, errors)
|
|
429
|
-
show_tokens: Include token counts in output
|
|
430
|
-
"""
|
|
392
|
+
def __init__(self, verbose: bool = False):
|
|
431
393
|
self.verbose = verbose
|
|
432
|
-
self.show_tokens = show_tokens
|
|
433
|
-
self._start_time: datetime | None = None
|
|
434
|
-
self._stage_times: dict[str, int] = {}
|
|
435
394
|
|
|
436
395
|
def report(self, update: ProgressUpdate) -> None:
|
|
437
|
-
"""Print progress to console.
|
|
438
|
-
|
|
439
|
-
Args:
|
|
440
|
-
update: Progress update from the tracker
|
|
441
|
-
"""
|
|
442
|
-
# Track start time for elapsed calculation
|
|
443
|
-
if self._start_time is None:
|
|
444
|
-
self._start_time = datetime.now()
|
|
445
|
-
|
|
446
|
-
percent = f"{update.percent_complete:3.0f}%"
|
|
396
|
+
"""Print progress to console."""
|
|
397
|
+
percent = f"{update.percent_complete:.0f}%"
|
|
447
398
|
cost = f"${update.cost_so_far:.4f}"
|
|
448
|
-
|
|
449
|
-
# Status icons that work in most environments
|
|
450
399
|
status_icon = {
|
|
451
400
|
ProgressStatus.PENDING: "○",
|
|
452
|
-
ProgressStatus.RUNNING: "
|
|
453
|
-
ProgressStatus.COMPLETED: "
|
|
401
|
+
ProgressStatus.RUNNING: "◐",
|
|
402
|
+
ProgressStatus.COMPLETED: "●",
|
|
454
403
|
ProgressStatus.FAILED: "✗",
|
|
455
|
-
ProgressStatus.SKIPPED: "
|
|
456
|
-
ProgressStatus.FALLBACK: "
|
|
404
|
+
ProgressStatus.SKIPPED: "◌",
|
|
405
|
+
ProgressStatus.FALLBACK: "↩",
|
|
457
406
|
ProgressStatus.RETRYING: "↻",
|
|
458
407
|
}.get(update.status, "?")
|
|
459
408
|
|
|
460
409
|
# Get current tier from running stage
|
|
461
410
|
tier_info = ""
|
|
462
|
-
model_info = ""
|
|
463
411
|
if update.current_stage and update.stages:
|
|
464
412
|
for stage in update.stages:
|
|
465
|
-
if stage.name == update.current_stage:
|
|
466
|
-
|
|
467
|
-
tier_info = f" [{stage.tier.upper()}]"
|
|
468
|
-
if stage.model:
|
|
469
|
-
model_info = f" ({stage.model})"
|
|
470
|
-
# Track stage duration
|
|
471
|
-
if stage.duration_ms > 0:
|
|
472
|
-
self._stage_times[stage.name] = stage.duration_ms
|
|
413
|
+
if stage.name == update.current_stage and stage.status == ProgressStatus.RUNNING:
|
|
414
|
+
tier_info = f" [{stage.tier.upper()}]"
|
|
473
415
|
break
|
|
474
416
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if self.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
tokens_str = ""
|
|
483
|
-
if self.show_tokens and update.tokens_so_far > 0:
|
|
484
|
-
tokens_str = f" | {update.tokens_so_far:,} tokens"
|
|
485
|
-
|
|
486
|
-
# Format: [100%] ✓ Completed optimize [PREMIUM] ($0.0279) [12.3s]
|
|
487
|
-
output = f"[{percent}] {status_icon} {update.message}{tier_info} ({cost}{tokens_str}){elapsed}"
|
|
488
|
-
print(output)
|
|
489
|
-
|
|
490
|
-
# Verbose output
|
|
491
|
-
if self.verbose:
|
|
492
|
-
if update.fallback_info:
|
|
493
|
-
print(f" ↳ Fallback: {update.fallback_info}")
|
|
494
|
-
if update.error:
|
|
495
|
-
print(f" ↳ Error: {update.error}")
|
|
496
|
-
|
|
497
|
-
# Print summary only on final workflow completion (not stage completion)
|
|
498
|
-
if update.status == ProgressStatus.COMPLETED and "workflow" in update.message.lower():
|
|
499
|
-
self._print_summary(update)
|
|
500
|
-
|
|
501
|
-
def _print_summary(self, update: ProgressUpdate) -> None:
|
|
502
|
-
"""Print workflow completion summary."""
|
|
503
|
-
if not self._stage_times:
|
|
504
|
-
return
|
|
505
|
-
|
|
506
|
-
print("")
|
|
507
|
-
print("─" * 50)
|
|
508
|
-
print("Stage Summary:")
|
|
509
|
-
for stage in update.stages:
|
|
510
|
-
if stage.status == ProgressStatus.COMPLETED:
|
|
511
|
-
duration_ms = stage.duration_ms or self._stage_times.get(stage.name, 0)
|
|
512
|
-
duration_str = f"{duration_ms}ms" if duration_ms < 1000 else f"{duration_ms/1000:.1f}s"
|
|
513
|
-
cost_str = f"${stage.cost:.4f}" if stage.cost > 0 else "—"
|
|
514
|
-
print(f" {stage.name}: {duration_str} | {cost_str}")
|
|
515
|
-
elif stage.status == ProgressStatus.SKIPPED:
|
|
516
|
-
print(f" {stage.name}: skipped")
|
|
517
|
-
print("─" * 50)
|
|
417
|
+
print(f"[{percent}] {status_icon} {update.message}{tier_info} ({cost})")
|
|
418
|
+
|
|
419
|
+
if self.verbose and update.fallback_info:
|
|
420
|
+
print(f" Fallback: {update.fallback_info}")
|
|
421
|
+
if self.verbose and update.error:
|
|
422
|
+
print(f" Error: {update.error}")
|
|
518
423
|
|
|
519
424
|
async def report_async(self, update: ProgressUpdate) -> None:
|
|
520
425
|
"""Async version just calls sync."""
|
|
@@ -558,6 +463,8 @@ def create_progress_tracker(
|
|
|
558
463
|
Configured ProgressTracker instance
|
|
559
464
|
|
|
560
465
|
"""
|
|
466
|
+
import uuid
|
|
467
|
+
|
|
561
468
|
tracker = ProgressTracker(
|
|
562
469
|
workflow_name=workflow_name,
|
|
563
470
|
workflow_id=uuid.uuid4().hex[:12],
|
|
@@ -568,212 +475,3 @@ def create_progress_tracker(
|
|
|
568
475
|
tracker.add_callback(reporter.report)
|
|
569
476
|
|
|
570
477
|
return tracker
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
class RichProgressReporter:
|
|
574
|
-
"""Rich-based live progress display with spinner, progress bar, and metrics.
|
|
575
|
-
|
|
576
|
-
Provides real-time visual feedback during workflow execution:
|
|
577
|
-
- Progress bar showing stage completion (1/3, 2/3, etc.)
|
|
578
|
-
- Spinner during active LLM API calls
|
|
579
|
-
- Real-time cost and token display
|
|
580
|
-
- In-place updates (no terminal scrolling)
|
|
581
|
-
|
|
582
|
-
Requires Rich library. Falls back gracefully if unavailable.
|
|
583
|
-
"""
|
|
584
|
-
|
|
585
|
-
def __init__(self, workflow_name: str, stage_names: list[str]) -> None:
|
|
586
|
-
"""Initialize the Rich progress reporter.
|
|
587
|
-
|
|
588
|
-
Args:
|
|
589
|
-
workflow_name: Name of the workflow for display
|
|
590
|
-
stage_names: List of stage names for progress tracking
|
|
591
|
-
"""
|
|
592
|
-
if not RICH_AVAILABLE:
|
|
593
|
-
raise RuntimeError("Rich library required for RichProgressReporter")
|
|
594
|
-
|
|
595
|
-
self.workflow_name = workflow_name
|
|
596
|
-
self.stage_names = stage_names
|
|
597
|
-
self.console = Console()
|
|
598
|
-
self._live: Live | None = None
|
|
599
|
-
self._progress: Progress | None = None
|
|
600
|
-
self._task_id: TaskID | None = None
|
|
601
|
-
self._current_stage = ""
|
|
602
|
-
self._cost = 0.0
|
|
603
|
-
self._tokens = 0
|
|
604
|
-
self._status = ProgressStatus.PENDING
|
|
605
|
-
|
|
606
|
-
def start(self) -> None:
|
|
607
|
-
"""Start the live progress display."""
|
|
608
|
-
if not RICH_AVAILABLE or Progress is None or Live is None:
|
|
609
|
-
return
|
|
610
|
-
|
|
611
|
-
self._progress = Progress(
|
|
612
|
-
SpinnerColumn(),
|
|
613
|
-
TextColumn("[bold blue]{task.description}"),
|
|
614
|
-
BarColumn(bar_width=30),
|
|
615
|
-
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
616
|
-
TextColumn("({task.completed}/{task.total})"),
|
|
617
|
-
console=self.console,
|
|
618
|
-
transient=False,
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
self._task_id = self._progress.add_task(
|
|
622
|
-
self.workflow_name,
|
|
623
|
-
total=len(self.stage_names),
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
self._live = Live(
|
|
627
|
-
self._create_display(),
|
|
628
|
-
console=self.console,
|
|
629
|
-
refresh_per_second=4,
|
|
630
|
-
transient=False,
|
|
631
|
-
)
|
|
632
|
-
self._live.start()
|
|
633
|
-
|
|
634
|
-
def stop(self) -> None:
|
|
635
|
-
"""Stop the live progress display."""
|
|
636
|
-
if self._live:
|
|
637
|
-
self._live.stop()
|
|
638
|
-
self._live = None
|
|
639
|
-
|
|
640
|
-
def report(self, update: ProgressUpdate) -> None:
|
|
641
|
-
"""Handle a progress update.
|
|
642
|
-
|
|
643
|
-
Args:
|
|
644
|
-
update: Progress update from the tracker
|
|
645
|
-
"""
|
|
646
|
-
self._current_stage = update.current_stage
|
|
647
|
-
self._cost = update.cost_so_far
|
|
648
|
-
self._tokens = update.tokens_so_far
|
|
649
|
-
self._status = update.status
|
|
650
|
-
|
|
651
|
-
# Update progress bar
|
|
652
|
-
if self._progress is not None and self._task_id is not None:
|
|
653
|
-
completed = sum(
|
|
654
|
-
1 for s in update.stages if s.status == ProgressStatus.COMPLETED
|
|
655
|
-
)
|
|
656
|
-
self._progress.update(
|
|
657
|
-
self._task_id,
|
|
658
|
-
completed=completed,
|
|
659
|
-
description=f"{self.workflow_name}: {update.current_stage}",
|
|
660
|
-
)
|
|
661
|
-
|
|
662
|
-
# Refresh display
|
|
663
|
-
if self._live:
|
|
664
|
-
self._live.update(self._create_display())
|
|
665
|
-
|
|
666
|
-
async def report_async(self, update: ProgressUpdate) -> None:
|
|
667
|
-
"""Async version of report."""
|
|
668
|
-
self.report(update)
|
|
669
|
-
|
|
670
|
-
def _create_display(self) -> Panel:
|
|
671
|
-
"""Create the Rich display panel.
|
|
672
|
-
|
|
673
|
-
Returns:
|
|
674
|
-
Rich Panel containing progress information
|
|
675
|
-
"""
|
|
676
|
-
if not RICH_AVAILABLE or Panel is None or Table is None:
|
|
677
|
-
raise RuntimeError("Rich not available")
|
|
678
|
-
|
|
679
|
-
# Build metrics table
|
|
680
|
-
metrics = Table(show_header=False, box=None, padding=(0, 2))
|
|
681
|
-
metrics.add_column("Label", style="dim")
|
|
682
|
-
metrics.add_column("Value", style="bold")
|
|
683
|
-
|
|
684
|
-
metrics.add_row("Cost:", f"${self._cost:.4f}")
|
|
685
|
-
metrics.add_row("Tokens:", f"{self._tokens:,}")
|
|
686
|
-
metrics.add_row("Stage:", self._current_stage or "Starting...")
|
|
687
|
-
|
|
688
|
-
# Status indicator
|
|
689
|
-
status_style = {
|
|
690
|
-
ProgressStatus.PENDING: "dim",
|
|
691
|
-
ProgressStatus.RUNNING: "blue",
|
|
692
|
-
ProgressStatus.COMPLETED: "green",
|
|
693
|
-
ProgressStatus.FAILED: "red",
|
|
694
|
-
ProgressStatus.FALLBACK: "yellow",
|
|
695
|
-
ProgressStatus.RETRYING: "yellow",
|
|
696
|
-
}.get(self._status, "white")
|
|
697
|
-
|
|
698
|
-
status_text = Text(self._status.value.upper(), style=status_style)
|
|
699
|
-
|
|
700
|
-
# Combine into panel
|
|
701
|
-
if self._progress is not None:
|
|
702
|
-
content = Group(self._progress, metrics)
|
|
703
|
-
else:
|
|
704
|
-
content = metrics
|
|
705
|
-
|
|
706
|
-
return Panel(
|
|
707
|
-
content,
|
|
708
|
-
title=f"[bold]{self.workflow_name}[/bold]",
|
|
709
|
-
subtitle=status_text,
|
|
710
|
-
border_style=status_style,
|
|
711
|
-
)
|
|
712
|
-
|
|
713
|
-
def __enter__(self) -> RichProgressReporter:
|
|
714
|
-
"""Context manager entry."""
|
|
715
|
-
self.start()
|
|
716
|
-
return self
|
|
717
|
-
|
|
718
|
-
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
719
|
-
"""Context manager exit."""
|
|
720
|
-
self.stop()
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
@contextmanager
|
|
724
|
-
def live_progress(
|
|
725
|
-
workflow_name: str,
|
|
726
|
-
stage_names: list[str],
|
|
727
|
-
console: Console | None = None,
|
|
728
|
-
) -> Generator[tuple[ProgressTracker, RichProgressReporter | None], None, None]:
|
|
729
|
-
"""Context manager for live progress display during workflow execution.
|
|
730
|
-
|
|
731
|
-
Provides a ProgressTracker with optional Rich-based live display.
|
|
732
|
-
Falls back gracefully when Rich is unavailable or output is not a TTY.
|
|
733
|
-
|
|
734
|
-
Args:
|
|
735
|
-
workflow_name: Name of the workflow
|
|
736
|
-
stage_names: List of stage names in order
|
|
737
|
-
console: Optional Rich Console (creates new one if not provided)
|
|
738
|
-
|
|
739
|
-
Yields:
|
|
740
|
-
Tuple of (ProgressTracker, RichProgressReporter or None)
|
|
741
|
-
|
|
742
|
-
Example:
|
|
743
|
-
with live_progress("Code Review", ["analyze", "review", "summarize"]) as (tracker, _):
|
|
744
|
-
tracker.start_workflow()
|
|
745
|
-
for stage in stages:
|
|
746
|
-
tracker.start_stage(stage)
|
|
747
|
-
# ... do work ...
|
|
748
|
-
tracker.complete_stage(stage, cost=0.01, tokens_in=100, tokens_out=50)
|
|
749
|
-
tracker.complete_workflow()
|
|
750
|
-
"""
|
|
751
|
-
tracker = ProgressTracker(
|
|
752
|
-
workflow_name=workflow_name,
|
|
753
|
-
workflow_id=uuid.uuid4().hex[:12],
|
|
754
|
-
stage_names=stage_names,
|
|
755
|
-
)
|
|
756
|
-
|
|
757
|
-
reporter: RichProgressReporter | None = None
|
|
758
|
-
|
|
759
|
-
# Use Rich if available and output is a TTY
|
|
760
|
-
if RICH_AVAILABLE and sys.stdout.isatty():
|
|
761
|
-
try:
|
|
762
|
-
reporter = RichProgressReporter(workflow_name, stage_names)
|
|
763
|
-
tracker.add_callback(reporter.report)
|
|
764
|
-
reporter.start()
|
|
765
|
-
except Exception: # noqa: BLE001
|
|
766
|
-
# INTENTIONAL: Rich display is optional - fall back to console output
|
|
767
|
-
reporter = None
|
|
768
|
-
simple_reporter = ConsoleProgressReporter(verbose=False)
|
|
769
|
-
tracker.add_callback(simple_reporter.report)
|
|
770
|
-
else:
|
|
771
|
-
# No Rich or not a TTY - use simple console reporter
|
|
772
|
-
simple_reporter = ConsoleProgressReporter(verbose=False)
|
|
773
|
-
tracker.add_callback(simple_reporter.report)
|
|
774
|
-
|
|
775
|
-
try:
|
|
776
|
-
yield tracker, reporter
|
|
777
|
-
finally:
|
|
778
|
-
if reporter:
|
|
779
|
-
reporter.stop()
|
empathy_os/workflows/routing.py
CHANGED
|
@@ -136,12 +136,7 @@ class BalancedRouting(TierRoutingStrategy):
|
|
|
136
136
|
|
|
137
137
|
Args:
|
|
138
138
|
total_budget: Total budget in USD for this workflow execution
|
|
139
|
-
|
|
140
|
-
Raises:
|
|
141
|
-
ValueError: If total_budget is not positive
|
|
142
139
|
"""
|
|
143
|
-
if total_budget <= 0:
|
|
144
|
-
raise ValueError("total_budget must be positive")
|
|
145
140
|
self.total_budget = total_budget
|
|
146
141
|
|
|
147
142
|
def route(self, context: RoutingContext) -> ModelTier:
|
|
@@ -342,29 +342,11 @@ class SecurityAuditWorkflow(BaseWorkflow):
|
|
|
342
342
|
if self._is_detection_code(line_content, match.group()):
|
|
343
343
|
continue
|
|
344
344
|
|
|
345
|
-
# Phase 2: Skip safe SQL parameterization patterns
|
|
346
|
-
if vuln_type == "sql_injection":
|
|
347
|
-
if self._is_safe_sql_parameterization(
|
|
348
|
-
line_content,
|
|
349
|
-
match.group(),
|
|
350
|
-
content,
|
|
351
|
-
):
|
|
352
|
-
continue
|
|
353
|
-
|
|
354
345
|
# Skip fake/test credentials
|
|
355
346
|
if vuln_type == "hardcoded_secret":
|
|
356
347
|
if self._is_fake_credential(match.group()):
|
|
357
348
|
continue
|
|
358
349
|
|
|
359
|
-
# Phase 2: Skip safe random usage (tests, demos, documented)
|
|
360
|
-
if vuln_type == "insecure_random":
|
|
361
|
-
if self._is_safe_random_usage(
|
|
362
|
-
line_content,
|
|
363
|
-
file_name,
|
|
364
|
-
content,
|
|
365
|
-
):
|
|
366
|
-
continue
|
|
367
|
-
|
|
368
350
|
# Skip command_injection in documentation strings
|
|
369
351
|
if vuln_type == "command_injection":
|
|
370
352
|
if self._is_documentation_or_string(
|
|
@@ -398,29 +380,6 @@ class SecurityAuditWorkflow(BaseWorkflow):
|
|
|
398
380
|
except OSError:
|
|
399
381
|
continue
|
|
400
382
|
|
|
401
|
-
# Phase 3: Apply AST-based filtering for command injection
|
|
402
|
-
try:
|
|
403
|
-
from .security_audit_phase3 import apply_phase3_filtering
|
|
404
|
-
|
|
405
|
-
# Separate command injection findings
|
|
406
|
-
cmd_findings = [f for f in findings if f["type"] == "command_injection"]
|
|
407
|
-
other_findings = [f for f in findings if f["type"] != "command_injection"]
|
|
408
|
-
|
|
409
|
-
# Apply Phase 3 filtering to command injection
|
|
410
|
-
filtered_cmd = apply_phase3_filtering(cmd_findings)
|
|
411
|
-
|
|
412
|
-
# Combine back
|
|
413
|
-
findings = other_findings + filtered_cmd
|
|
414
|
-
|
|
415
|
-
logger.info(
|
|
416
|
-
f"Phase 3: Filtered command_injection from {len(cmd_findings)} to {len(filtered_cmd)} "
|
|
417
|
-
f"({len(cmd_findings) - len(filtered_cmd)} false positives removed)"
|
|
418
|
-
)
|
|
419
|
-
except ImportError:
|
|
420
|
-
logger.debug("Phase 3 module not available, skipping AST-based filtering")
|
|
421
|
-
except Exception as e:
|
|
422
|
-
logger.warning(f"Phase 3 filtering failed: {e}")
|
|
423
|
-
|
|
424
383
|
input_tokens = len(str(input_data)) // 4
|
|
425
384
|
output_tokens = len(str(findings)) // 4
|
|
426
385
|
|
|
@@ -582,154 +541,6 @@ class SecurityAuditWorkflow(BaseWorkflow):
|
|
|
582
541
|
|
|
583
542
|
return False
|
|
584
543
|
|
|
585
|
-
def _is_safe_sql_parameterization(self, line_content: str, match_text: str, file_content: str) -> bool:
|
|
586
|
-
"""Check if SQL query uses safe parameterization despite f-string usage.
|
|
587
|
-
|
|
588
|
-
Phase 2 Enhancement: Detects safe patterns like:
|
|
589
|
-
- placeholders = ",".join("?" * len(ids))
|
|
590
|
-
- cursor.execute(f"... IN ({placeholders})", ids)
|
|
591
|
-
|
|
592
|
-
This prevents false positives for the SQLite-recommended pattern
|
|
593
|
-
of building dynamic placeholder strings.
|
|
594
|
-
|
|
595
|
-
Args:
|
|
596
|
-
line_content: The line containing the match (may be incomplete for multi-line)
|
|
597
|
-
match_text: The matched text
|
|
598
|
-
file_content: Full file content for context analysis
|
|
599
|
-
|
|
600
|
-
Returns:
|
|
601
|
-
True if this is safe parameterized SQL, False otherwise
|
|
602
|
-
"""
|
|
603
|
-
# Get the position of the match in the full file content
|
|
604
|
-
match_pos = file_content.find(match_text)
|
|
605
|
-
if match_pos == -1:
|
|
606
|
-
# Try to find cursor.execute
|
|
607
|
-
match_pos = file_content.find("cursor.execute")
|
|
608
|
-
if match_pos == -1:
|
|
609
|
-
return False
|
|
610
|
-
|
|
611
|
-
# Extract a larger context (next 200 chars after match)
|
|
612
|
-
context = file_content[match_pos:match_pos + 200]
|
|
613
|
-
|
|
614
|
-
# Also get lines before the match for placeholder detection
|
|
615
|
-
lines_before = file_content[:match_pos].split("\n")
|
|
616
|
-
recent_lines = lines_before[-10:] if len(lines_before) > 10 else lines_before
|
|
617
|
-
|
|
618
|
-
# Pattern 1: Check if this is a placeholder-based parameterized query
|
|
619
|
-
# Look for: cursor.execute(f"... IN ({placeholders})", params)
|
|
620
|
-
if "placeholders" in context or any("placeholders" in line for line in recent_lines[-5:]):
|
|
621
|
-
# Check if context has both f-string and separate parameters
|
|
622
|
-
# Pattern: f"...{placeholders}..." followed by comma and params
|
|
623
|
-
if re.search(r'f["\'][^"\']*\{placeholders\}[^"\']*["\']\s*,\s*\w+', context):
|
|
624
|
-
return True # Safe - has separate parameters
|
|
625
|
-
|
|
626
|
-
# Also check if recent lines built the placeholders
|
|
627
|
-
for prev_line in reversed(recent_lines):
|
|
628
|
-
if "placeholders" in prev_line and '"?"' in prev_line and "join" in prev_line:
|
|
629
|
-
# Found placeholder construction
|
|
630
|
-
# Now check if the execute has separate parameters
|
|
631
|
-
if "," in context and any(param in context for param in ["run_ids", "ids", "params", "values", ")"]):
|
|
632
|
-
return True
|
|
633
|
-
|
|
634
|
-
# Pattern 2: Check if f-string only builds SQL structure with constants
|
|
635
|
-
# Example: f"SELECT * FROM {TABLE_NAME}" where TABLE_NAME is a constant
|
|
636
|
-
f_string_vars = re.findall(r'\{(\w+)\}', context)
|
|
637
|
-
if f_string_vars:
|
|
638
|
-
# Check if all variables are constants (UPPERCASE or table/column names)
|
|
639
|
-
all_constants = all(
|
|
640
|
-
var.isupper() or "TABLE" in var.upper() or "COLUMN" in var.upper()
|
|
641
|
-
for var in f_string_vars
|
|
642
|
-
)
|
|
643
|
-
if all_constants:
|
|
644
|
-
return True # Safe - using constants, not user data
|
|
645
|
-
|
|
646
|
-
# Pattern 3: Check for security note comments nearby
|
|
647
|
-
# If developers added security notes, it's likely safe
|
|
648
|
-
for prev_line in reversed(recent_lines[-3:]):
|
|
649
|
-
if "security note" in prev_line.lower() and "safe" in prev_line.lower():
|
|
650
|
-
return True
|
|
651
|
-
|
|
652
|
-
return False
|
|
653
|
-
|
|
654
|
-
def _is_safe_random_usage(self, line_content: str, file_path: str, file_content: str) -> bool:
|
|
655
|
-
"""Check if random usage is in a safe context (tests, simulations, non-crypto).
|
|
656
|
-
|
|
657
|
-
Phase 2 Enhancement: Reduces false positives for random module usage
|
|
658
|
-
in test fixtures, A/B testing simulations, and demo code.
|
|
659
|
-
|
|
660
|
-
Args:
|
|
661
|
-
line_content: The line containing the match
|
|
662
|
-
file_path: Path to the file being scanned
|
|
663
|
-
file_content: Full file content for context analysis
|
|
664
|
-
|
|
665
|
-
Returns:
|
|
666
|
-
True if random usage is safe/documented, False if potentially insecure
|
|
667
|
-
"""
|
|
668
|
-
# Check if file is a test file
|
|
669
|
-
is_test = any(pattern in file_path.lower() for pattern in ["/test", "test_", "conftest"])
|
|
670
|
-
|
|
671
|
-
# Check for explicit security notes nearby
|
|
672
|
-
lines = file_content.split("\n")
|
|
673
|
-
line_index = None
|
|
674
|
-
for i, line in enumerate(lines):
|
|
675
|
-
if line_content.strip() in line:
|
|
676
|
-
line_index = i
|
|
677
|
-
break
|
|
678
|
-
|
|
679
|
-
if line_index is not None:
|
|
680
|
-
# Check 5 lines before and after for security notes
|
|
681
|
-
context_start = max(0, line_index - 5)
|
|
682
|
-
context_end = min(len(lines), line_index + 5)
|
|
683
|
-
context = "\n".join(lines[context_start:context_end]).lower()
|
|
684
|
-
|
|
685
|
-
# Look for clarifying comments
|
|
686
|
-
safe_indicators = [
|
|
687
|
-
"security note",
|
|
688
|
-
"not cryptographic",
|
|
689
|
-
"not for crypto",
|
|
690
|
-
"test data",
|
|
691
|
-
"demo data",
|
|
692
|
-
"simulation",
|
|
693
|
-
"reproducible",
|
|
694
|
-
"deterministic",
|
|
695
|
-
"fixed seed",
|
|
696
|
-
"not used for security",
|
|
697
|
-
"not used for secrets",
|
|
698
|
-
"not used for tokens",
|
|
699
|
-
]
|
|
700
|
-
|
|
701
|
-
if any(indicator in context for indicator in safe_indicators):
|
|
702
|
-
return True # Documented as safe
|
|
703
|
-
|
|
704
|
-
# Check for common safe random patterns
|
|
705
|
-
line_lower = line_content.lower()
|
|
706
|
-
|
|
707
|
-
# Pattern 1: Fixed seed (reproducible tests)
|
|
708
|
-
if "random.seed(" in line_lower:
|
|
709
|
-
return True # Fixed seed is for reproducibility, not security
|
|
710
|
-
|
|
711
|
-
# Pattern 2: A/B testing, simulations, demos
|
|
712
|
-
safe_contexts = [
|
|
713
|
-
"simulation",
|
|
714
|
-
"demo",
|
|
715
|
-
"a/b test",
|
|
716
|
-
"ab_test",
|
|
717
|
-
"fixture",
|
|
718
|
-
"mock",
|
|
719
|
-
"example",
|
|
720
|
-
"sample",
|
|
721
|
-
]
|
|
722
|
-
if any(context in file_path.lower() for context in safe_contexts):
|
|
723
|
-
return True
|
|
724
|
-
|
|
725
|
-
# If it's a test file without crypto indicators, it's probably safe
|
|
726
|
-
if is_test:
|
|
727
|
-
crypto_indicators = ["password", "secret", "token", "key", "crypto", "auth"]
|
|
728
|
-
if not any(indicator in file_path.lower() for indicator in crypto_indicators):
|
|
729
|
-
return True
|
|
730
|
-
|
|
731
|
-
return False
|
|
732
|
-
|
|
733
544
|
async def _assess(self, input_data: dict, tier: ModelTier) -> tuple[dict, int, int]:
|
|
734
545
|
"""Risk scoring and severity classification.
|
|
735
546
|
|
|
@@ -222,11 +222,31 @@ def enhanced_command_injection_detection(
|
|
|
222
222
|
if is_scanner_implementation_file(file_path):
|
|
223
223
|
return [] # Scanner files are allowed to mention eval/exec
|
|
224
224
|
|
|
225
|
-
# Step 2: For Python files, use AST-based detection
|
|
225
|
+
# Step 2: For Python files, use AST-based detection for eval/exec only
|
|
226
|
+
# Keep subprocess findings from regex detection
|
|
226
227
|
if file_path.endswith(".py"):
|
|
227
228
|
try:
|
|
229
|
+
# Separate eval/exec findings from subprocess/os.system findings
|
|
230
|
+
# Eval/exec findings will be replaced with AST-based findings
|
|
231
|
+
# Subprocess/os.system findings will be kept from regex detection
|
|
232
|
+
eval_exec_findings = []
|
|
233
|
+
subprocess_findings = []
|
|
234
|
+
|
|
235
|
+
for finding in original_findings:
|
|
236
|
+
match_text = finding.get("match", "").lower()
|
|
237
|
+
if "eval" in match_text or "exec" in match_text:
|
|
238
|
+
eval_exec_findings.append(finding)
|
|
239
|
+
else:
|
|
240
|
+
# subprocess, os.system, or other command injection patterns
|
|
241
|
+
subprocess_findings.append(finding)
|
|
242
|
+
|
|
243
|
+
# Use AST to validate eval/exec findings (reduces false positives)
|
|
228
244
|
ast_findings = analyze_file_for_eval_exec(file_path)
|
|
229
245
|
|
|
246
|
+
# Check if this is a test file (downgrade severity)
|
|
247
|
+
from .security_audit import TEST_FILE_PATTERNS
|
|
248
|
+
is_test_file = any(re.search(pat, file_path) for pat in TEST_FILE_PATTERNS)
|
|
249
|
+
|
|
230
250
|
# Convert AST findings to format compatible with original
|
|
231
251
|
filtered = []
|
|
232
252
|
for finding in ast_findings:
|
|
@@ -235,11 +255,15 @@ def enhanced_command_injection_detection(
|
|
|
235
255
|
"file": file_path,
|
|
236
256
|
"line": finding["line"],
|
|
237
257
|
"match": f"{finding['function']}(",
|
|
238
|
-
"severity": "critical",
|
|
258
|
+
"severity": "low" if is_test_file else "critical",
|
|
239
259
|
"owasp": "A03:2021 Injection",
|
|
240
260
|
"context": finding.get("context", ""),
|
|
261
|
+
"is_test": is_test_file,
|
|
241
262
|
})
|
|
242
263
|
|
|
264
|
+
# Keep subprocess/os.system findings (not filtered by AST)
|
|
265
|
+
filtered.extend(subprocess_findings)
|
|
266
|
+
|
|
243
267
|
return filtered
|
|
244
268
|
|
|
245
269
|
except Exception as e:
|