Files
Aperant/apps/backend/security/profile.py
T
Michael Ludlow 20f20fa321 fix(security): invalidate profile cache when file is created/modified (#355)
* fix(security): invalidate profile cache when file is created/modified

Fixes #153

The security profile cache was returning stale data even after the
.auto-claude-security.json file was created or updated. This caused
commands like 'dotnet' to be blocked even when present in the file.

Root cause: get_security_profile() cached the profile on first call
without checking if the file's mtime changed on subsequent calls.

Fix: Track the security profile file's mtime and invalidate the cache
when the file is created (mtime goes from None to a value) or modified
(mtime changes).

This also helps with issue #222 where the profile is created after the
agent starts - now the agent will pick up the new profile on the next
command validation.

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>

* fix(security): handle deleted profile file and add cache invalidation tests

* fix(security): handle deleted profile file and add cache invalidation tests

* test(security): improve cache tests with mocks and unique commands

* test(security): add mock-free tests for cache invalidation

* test(security): fix cache invalidation tests without mocks

* fix(security): address review comments and add debug logs for CI hash failure

* fix(analyzer): remove debug prints

* fix(lint): sort imports in profile.py

* fix(security): include spec_dir in cache key to prevent stale profiles

The cache key previously only included project_dir, but the profile
location can depend on spec_dir. This could cause stale cached profiles
to be returned if spec_dir changes between calls.

Fix: Add _cached_spec_dir to the cache validation logic and reset function.

---------

Signed-off-by: Black Circle Sentinel <mludlow000@icloud.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-27 22:59:39 +01:00

98 lines
3.3 KiB
Python

"""
Security Profile Management
============================
Manages security profiles for projects, including caching and validation.
Uses project_analyzer to create dynamic security profiles based on detected stacks.
"""
from pathlib import Path
from project_analyzer import (
ProjectAnalyzer,
SecurityProfile,
get_or_create_profile,
)
# =============================================================================
# GLOBAL STATE
# =============================================================================
# Cache the security profile to avoid re-analyzing on every command
_cached_profile: SecurityProfile | None = None
_cached_project_dir: Path | None = None
_cached_spec_dir: Path | None = None # Track spec directory for cache key
_cached_profile_mtime: float | None = None # Track file modification time
def _get_profile_path(project_dir: Path) -> Path:
"""Get the security profile file path for a project."""
return project_dir / ProjectAnalyzer.PROFILE_FILENAME
def _get_profile_mtime(project_dir: Path) -> float | None:
"""Get the modification time of the security profile file, or None if not exists."""
profile_path = _get_profile_path(project_dir)
try:
return profile_path.stat().st_mtime if profile_path.exists() else None
except OSError:
return None
def get_security_profile(
project_dir: Path, spec_dir: Path | None = None
) -> SecurityProfile:
"""
Get the security profile for a project, using cache when possible.
The cache is invalidated when:
- The project directory changes
- The security profile file is created (was None, now exists)
- The security profile file is modified (mtime changed)
Args:
project_dir: Project root directory
spec_dir: Optional spec directory
Returns:
SecurityProfile for the project
"""
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
project_dir = Path(project_dir).resolve()
resolved_spec_dir = Path(spec_dir).resolve() if spec_dir else None
# Check if cache is valid (both project_dir and spec_dir must match)
if (
_cached_profile is not None
and _cached_project_dir == project_dir
and _cached_spec_dir == resolved_spec_dir
):
# Check if file has been created or modified since caching
current_mtime = _get_profile_mtime(project_dir)
# Cache is valid if:
# - Both are None (file never existed and still doesn't)
# - Both have same mtime (file unchanged)
if current_mtime == _cached_profile_mtime:
return _cached_profile
# File was created or modified - invalidate cache
# (This happens when analyzer creates the file after agent starts)
# Analyze and cache
_cached_profile = get_or_create_profile(project_dir, spec_dir)
_cached_project_dir = project_dir
_cached_spec_dir = resolved_spec_dir
_cached_profile_mtime = _get_profile_mtime(project_dir)
return _cached_profile
def reset_profile_cache() -> None:
"""Reset the cached profile (useful for testing or re-analysis)."""
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
_cached_profile = None
_cached_project_dir = None
_cached_spec_dir = None
_cached_profile_mtime = None