Merge pull request 'develop-hamza-1' (#120) from develop-hamza-1 into master
CI / lint (push) Successful in 13s
CI / security (push) Successful in 34s
CI / typecheck (push) Successful in 48s
CI / quality (push) Successful in 47s
CI / benchmark-regression (push) Has been skipped
CI / build (push) Successful in 36s
CI / integration_tests (push) Successful in 3m30s
CI / unit_tests (push) Successful in 4m33s
CI / docker (push) Successful in 8s
CI / benchmark-publish (push) Failing after 8m15s
CI / coverage (push) Successful in 21m42s
CI / lint (push) Successful in 13s
CI / security (push) Successful in 34s
CI / typecheck (push) Successful in 48s
CI / quality (push) Successful in 47s
CI / benchmark-regression (push) Has been skipped
CI / build (push) Successful in 36s
CI / integration_tests (push) Successful in 3m30s
CI / unit_tests (push) Successful in 4m33s
CI / docker (push) Successful in 8s
CI / benchmark-publish (push) Failing after 8m15s
CI / coverage (push) Successful in 21m42s
Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""ASV benchmarks for CONC3 cleanup service throughput.
|
||||
|
||||
Measures the performance of scan and purge operations to ensure
|
||||
cleanup overhead stays within acceptable bounds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.cleanup_service import ( # noqa: E402
|
||||
CleanupService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings # noqa: E402
|
||||
|
||||
|
||||
def _make_settings(**overrides: object) -> Settings:
|
||||
"""Create Settings with overrides."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
for key, value in overrides.items():
|
||||
setattr(settings, key, value)
|
||||
return settings
|
||||
|
||||
|
||||
class ScanBenchmark:
|
||||
"""Benchmark cleanup scan operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
self.service = CleanupService(
|
||||
settings=self.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
def time_scan_empty(self) -> None:
|
||||
"""Scan with no stale resources."""
|
||||
self.service.scan()
|
||||
|
||||
|
||||
class CheckpointBenchmark:
|
||||
"""Benchmark checkpoint scanning and pruning."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.settings = _make_settings(cleanup_checkpoint_max_per_plan=50)
|
||||
self.service = CleanupService(
|
||||
settings=self.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
self.checkpoint_dir = Path(
|
||||
tempfile.mkdtemp(prefix="ca-bench-checkpoint-"),
|
||||
)
|
||||
for i in range(100):
|
||||
f = self.checkpoint_dir / f"checkpoint_{i:04d}.json"
|
||||
f.write_text(f'{{"step": {i}}}')
|
||||
|
||||
def teardown(self) -> None:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.checkpoint_dir, ignore_errors=True)
|
||||
|
||||
def time_scan_checkpoints(self) -> None:
|
||||
self.service.scan_checkpoints_for_plan(self.checkpoint_dir)
|
||||
|
||||
def time_prune_checkpoints(self) -> None:
|
||||
self.service.prune_checkpoints_for_plan(self.checkpoint_dir)
|
||||
|
||||
|
||||
class SessionScanBenchmark:
|
||||
"""Benchmark session inactivity scanning."""
|
||||
|
||||
def setup(self) -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
self.settings = _make_settings(
|
||||
cleanup_session_inactivity_days=30,
|
||||
)
|
||||
self.service = CleanupService(
|
||||
settings=self.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
self.sessions = [
|
||||
{
|
||||
"session_id": f"sess-{i:04d}",
|
||||
"updated_at": now - timedelta(days=i),
|
||||
}
|
||||
for i in range(100)
|
||||
]
|
||||
|
||||
def time_scan_sessions(self) -> None:
|
||||
self.service.scan_inactive_sessions(self.sessions)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""ASV benchmarks for SEC5 secrets redaction throughput.
|
||||
|
||||
Measures the performance of the core redaction functions to ensure
|
||||
masking overhead stays within acceptable bounds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.shared.redaction import ( # noqa: E402
|
||||
is_sensitive_key,
|
||||
mask_database_url,
|
||||
redact_dict,
|
||||
redact_value,
|
||||
secrets_masking_processor,
|
||||
)
|
||||
|
||||
# ── Sample data ───────────────────────────────────────────────────
|
||||
|
||||
_SECRET_STRING = "My API key is sk-proj-abc123def456ghi789jkl012mno345"
|
||||
_CLEAN_STRING = "This is a perfectly normal log message with no secrets"
|
||||
_MIXED_DICT: dict[str, object] = {
|
||||
"api_key": "sk-proj-abc123def456ghi789",
|
||||
"username": "alice",
|
||||
"password": "hunter2",
|
||||
"config": {
|
||||
"token": "tok_01HXYZ1234567890ABCDEF",
|
||||
"endpoint": "https://api.example.com",
|
||||
},
|
||||
"message": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWI",
|
||||
}
|
||||
_POSTGRES_URL = "postgresql://admin:s3cret@db.prod.internal:5432/mydb"
|
||||
_SQLITE_URL = "sqlite:///data/cleveragents.db"
|
||||
|
||||
|
||||
# ── Benchmarks ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RedactValue:
|
||||
"""Benchmark ``redact_value`` for secret and clean strings."""
|
||||
|
||||
def time_redact_secret_string(self) -> None:
|
||||
redact_value(_SECRET_STRING)
|
||||
|
||||
def time_redact_clean_string(self) -> None:
|
||||
redact_value(_CLEAN_STRING)
|
||||
|
||||
def time_redact_empty_string(self) -> None:
|
||||
redact_value("")
|
||||
|
||||
|
||||
class RedactDict:
|
||||
"""Benchmark ``redact_dict`` for nested dicts."""
|
||||
|
||||
def time_redact_mixed_dict(self) -> None:
|
||||
redact_dict(_MIXED_DICT) # type: ignore[arg-type]
|
||||
|
||||
def time_redact_empty_dict(self) -> None:
|
||||
redact_dict({})
|
||||
|
||||
|
||||
class IsSensitiveKey:
|
||||
"""Benchmark ``is_sensitive_key`` lookups."""
|
||||
|
||||
def time_sensitive_key(self) -> None:
|
||||
is_sensitive_key("api_key")
|
||||
|
||||
def time_non_sensitive_key(self) -> None:
|
||||
is_sensitive_key("username")
|
||||
|
||||
def time_false_positive_key(self) -> None:
|
||||
is_sensitive_key("token_count")
|
||||
|
||||
|
||||
class MaskDatabaseUrl:
|
||||
"""Benchmark ``mask_database_url`` for various URL schemes."""
|
||||
|
||||
def time_mask_postgres_url(self) -> None:
|
||||
mask_database_url(_POSTGRES_URL)
|
||||
|
||||
def time_mask_sqlite_url(self) -> None:
|
||||
mask_database_url(_SQLITE_URL)
|
||||
|
||||
|
||||
class StructlogProcessor:
|
||||
"""Benchmark the structlog masking processor."""
|
||||
|
||||
def time_processor_with_secrets(self) -> None:
|
||||
event = dict(_MIXED_DICT)
|
||||
event["event"] = "Request failed with key sk-ant-api03-xyz123abc456"
|
||||
secrets_masking_processor(None, "info", event)
|
||||
|
||||
def time_processor_clean_event(self) -> None:
|
||||
event = {"event": "User logged in", "user": "alice", "status": "ok"}
|
||||
secrets_masking_processor(None, "info", event)
|
||||
@@ -0,0 +1,169 @@
|
||||
# Cleanup / Garbage Collection
|
||||
|
||||
The `agents cleanup` command group provides garbage collection for stale
|
||||
platform resources: sandboxes, checkpoints, sessions, logs, and backups.
|
||||
All operations respect configurable retention policies and protect active
|
||||
resources from accidental deletion.
|
||||
|
||||
---
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `agents cleanup scan`
|
||||
|
||||
Dry-run scan that identifies stale resources without deleting anything.
|
||||
Prints a per-resource summary table and lists every item that would be
|
||||
cleaned.
|
||||
|
||||
```
|
||||
agents cleanup scan
|
||||
```
|
||||
|
||||
### `agents cleanup purge`
|
||||
|
||||
Remove stale resources based on retention policies.
|
||||
|
||||
```
|
||||
agents cleanup purge [--dry-run] [--all] [--yes|-y]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--dry-run` | `false` | Report what would be removed without deleting. |
|
||||
| `--all` | `false` | Purge all resource types (sandboxes, checkpoints, sessions, logs, backups). Without this flag, only sandboxes and checkpoints are purged. |
|
||||
| `--yes`, `-y` | `false` | Skip the interactive confirmation prompt. |
|
||||
|
||||
### `agents cleanup status`
|
||||
|
||||
Display current retention policy settings.
|
||||
|
||||
```
|
||||
agents cleanup status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource Types
|
||||
|
||||
### Sandboxes
|
||||
|
||||
Sandbox directories live in the system temp directory with prefixes
|
||||
`ca-sandbox-` and `ca-cow-sandbox-`. A sandbox is considered stale when
|
||||
its modification time exceeds `cleanup_sandbox_max_age_hours`. Sandboxes
|
||||
linked to a currently-running plan are skipped and logged.
|
||||
|
||||
### Checkpoints
|
||||
|
||||
LangGraph checkpoint files (`checkpoint_*.json`) reside under
|
||||
`<data_dir>/checkpoints/<plan_id>/`. When a plan accumulates more
|
||||
checkpoints than `cleanup_checkpoint_max_per_plan`, the oldest files are
|
||||
pruned while always keeping the first and most recent checkpoint.
|
||||
|
||||
### Sessions
|
||||
|
||||
Sessions in the SQLite database that have not been updated for longer than
|
||||
`cleanup_session_inactivity_days` are eligible for removal. Session
|
||||
cleanup is only performed when `--all` is passed to `purge`.
|
||||
|
||||
### Logs
|
||||
|
||||
Log files (`*.log`) under `<log_dir>` older than
|
||||
`cleanup_log_retention_days` are removed during an `--all` purge.
|
||||
|
||||
### Backups
|
||||
|
||||
Backup files under `<data_dir>/backups/` older than
|
||||
`cleanup_backup_retention_days` are removed during an `--all` purge.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All retention policies are configurable via environment variables or the
|
||||
Settings model.
|
||||
|
||||
| Setting | Env Variable | Default | Description |
|
||||
|---------|-------------|---------|-------------|
|
||||
| `cleanup_sandbox_max_age_hours` | `CLEVERAGENTS_CLEANUP_SANDBOX_MAX_AGE_HOURS` | `48` | Max sandbox age in hours before eligibility. |
|
||||
| `cleanup_checkpoint_max_per_plan` | `CLEVERAGENTS_CHECKPOINT_MAX` | `50` | Max checkpoints retained per plan. |
|
||||
| `cleanup_session_inactivity_days` | `CLEVERAGENTS_CLEANUP_SESSION_INACTIVITY_DAYS` | `30` | Days of inactivity before session is stale. |
|
||||
| `cleanup_log_retention_days` | `CLEVERAGENTS_LOG_RETENTION_DAYS` | `30` | Days to retain log files. |
|
||||
| `cleanup_backup_retention_days` | `CLEVERAGENTS_BACKUP_RETENTION_DAYS` | `7` | Days to retain backup snapshots. |
|
||||
| `cleanup_schedule` | `CLEVERAGENTS_CLEANUP_SCHEDULE` | `manual` | `manual` (user-initiated) or `auto` (stub). |
|
||||
|
||||
These correspond to the specification config keys:
|
||||
|
||||
- `sandbox.cleanup` → `manual` (MVP)
|
||||
- `sandbox.checkpoint.max-per-plan` → 50
|
||||
- `core.log.retention-days` → 30
|
||||
- `core.backup.retention-days` → 7
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
- **Active plan protection**: Sandboxes linked to a running plan ID are
|
||||
never deleted. The skipped items and reasons appear in the report.
|
||||
- **Confirmation prompt**: `purge` requires interactive confirmation
|
||||
unless `--yes` is passed.
|
||||
- **Dry-run**: Both `scan` and `purge --dry-run` preview cleanup without
|
||||
side effects.
|
||||
- **Checkpoint preservation**: The first and most recent checkpoint for
|
||||
each plan are always retained, even when pruning excess files.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
Scan for stale resources:
|
||||
|
||||
```
|
||||
$ agents cleanup scan
|
||||
Cleanup Scan Results (dry-run)
|
||||
|
||||
Mode: Dry-run
|
||||
Sandboxes scanned=4 to remove=2 skipped=1
|
||||
Checkpoints scanned=120 to remove=15 skipped=0
|
||||
Sessions scanned=0 to remove=0 skipped=0
|
||||
Logs scanned=8 to remove=3 skipped=0
|
||||
Backups scanned=5 to remove=2 skipped=0
|
||||
|
||||
Stale items found:
|
||||
[sandbox] /tmp/ca-sandbox-plan123-abcd (3 days old)
|
||||
[checkpoint] .cleveragents/checkpoints/plan456/checkpoint_003.json (excess)
|
||||
...
|
||||
```
|
||||
|
||||
Purge only sandboxes and checkpoints (default):
|
||||
|
||||
```
|
||||
$ agents cleanup purge --yes
|
||||
Cleanup Complete
|
||||
|
||||
Mode: Purge
|
||||
Sandboxes scanned=4 removed=2 skipped=1
|
||||
Checkpoints scanned=120 removed=15 skipped=0
|
||||
Sessions scanned=0 removed=0 skipped=0
|
||||
Logs scanned=0 removed=0 skipped=0
|
||||
Backups scanned=0 removed=0 skipped=0
|
||||
```
|
||||
|
||||
Purge all resource types with dry-run preview:
|
||||
|
||||
```
|
||||
$ agents cleanup purge --all --dry-run
|
||||
```
|
||||
|
||||
View current retention settings:
|
||||
|
||||
```
|
||||
$ agents cleanup status
|
||||
Cleanup Retention Policies
|
||||
|
||||
Sandbox max age: 48 hours
|
||||
Checkpoint max/plan: 50
|
||||
Session inactivity: 30 days
|
||||
Log retention: 30 days
|
||||
Backup retention: 7 days
|
||||
Schedule: manual
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
# Secrets Handling (SEC5)
|
||||
|
||||
CleverAgents automatically masks API keys, tokens, and credentials in
|
||||
CLI output, structured logs, and error messages. This document covers
|
||||
the design and usage of the redaction subsystem.
|
||||
|
||||
## Overview
|
||||
|
||||
The `cleveragents.shared.redaction` module provides centralised,
|
||||
pattern-based secret detection and masking. It is integrated into:
|
||||
|
||||
- **CLI output** via `format_output()` in `cleveragents.cli.formatting`
|
||||
- **Error handlers** in `main.py`, `project.py`, and `auto_debug.py`
|
||||
- **structlog processors** via `secrets_masking_processor`
|
||||
- **Settings repr** via `Settings.__repr__`
|
||||
|
||||
## Detected patterns
|
||||
|
||||
| Pattern family | Regex summary | Example |
|
||||
|---|---|---|
|
||||
| OpenAI keys | `sk-(?:proj-)?[A-Za-z0-9_-]{10,}` | `sk-proj-abc123…` |
|
||||
| Anthropic keys | `sk-ant-[A-Za-z0-9_-]{10,}` | `sk-ant-api03-xyz…` |
|
||||
| Token IDs | `tok_[A-Za-z0-9]{10,}` | `tok_01HXYZ…` |
|
||||
| Bearer tokens | `Bearer\s+[A-Za-z0-9._~+/=-]{20,}` | `Bearer eyJhb…` |
|
||||
| Generic keys | `(?:key\|KEY)-[A-Za-z0-9]{20,}` | `KEY-abcdef…` |
|
||||
|
||||
Sensitive **key names** (dictionary keys, config fields) are also
|
||||
detected by substring matching:
|
||||
|
||||
`api_key`, `apikey`, `password`, `passwd`, `secret`, `token`,
|
||||
`credential`, `private_key`, `access_key`, `auth`
|
||||
|
||||
False-positive key names like `token_count`, `max_tokens`, etc. are
|
||||
excluded.
|
||||
|
||||
## Replacement token
|
||||
|
||||
All detected secrets are replaced with `***REDACTED***`.
|
||||
|
||||
## Global `--show-secrets` flag
|
||||
|
||||
Pass `--show-secrets` to any CLI command to reveal secrets in that
|
||||
invocation:
|
||||
|
||||
```bash
|
||||
agents --show-secrets info
|
||||
agents --show-secrets diagnostics
|
||||
```
|
||||
|
||||
The flag sets a global thread-safe boolean that the redaction layer
|
||||
checks. When `True`, all masking is bypassed.
|
||||
|
||||
Programmatic access:
|
||||
|
||||
```python
|
||||
from cleveragents.shared.redaction import get_show_secrets, set_show_secrets
|
||||
|
||||
set_show_secrets(True) # reveal secrets
|
||||
set_show_secrets(False) # re-enable masking (default)
|
||||
```
|
||||
|
||||
## Custom patterns
|
||||
|
||||
Register additional secret patterns at runtime:
|
||||
|
||||
```python
|
||||
from cleveragents.shared.redaction import register_pattern
|
||||
|
||||
register_pattern(r"myorg_[a-z0-9]{32}")
|
||||
```
|
||||
|
||||
Patterns are compiled once and appended to the global list.
|
||||
|
||||
## Database URL masking
|
||||
|
||||
`mask_database_url()` replaces embedded passwords in connection
|
||||
strings while preserving the host and database name:
|
||||
|
||||
```python
|
||||
from cleveragents.shared.redaction import mask_database_url
|
||||
|
||||
mask_database_url("postgresql://user:pass@host/db")
|
||||
# → "postgresql://user:***@host/db"
|
||||
|
||||
mask_database_url("sqlite:///data.db")
|
||||
# → "sqlite:///data.db" (unchanged)
|
||||
```
|
||||
|
||||
## structlog integration
|
||||
|
||||
The `secrets_masking_processor` is inserted into the structlog
|
||||
processor chain by `configure_structlog()`:
|
||||
|
||||
```python
|
||||
from cleveragents.config.logging import configure_structlog
|
||||
|
||||
configure_structlog(env="production", log_level="INFO")
|
||||
```
|
||||
|
||||
Every log event is scanned for secret patterns before rendering.
|
||||
|
||||
## Settings safety
|
||||
|
||||
`Settings.__repr__()` masks any field whose name matches a sensitive
|
||||
key pattern, so accidental `print(settings)` or debug output never
|
||||
leaks credentials.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Behave**: `features/security_secrets.feature` (43 scenarios)
|
||||
- **Robot Framework**: `robot/security_secrets.robot` (10 smoke tests)
|
||||
- **ASV benchmarks**: `benchmarks/security_secrets_bench.py`
|
||||
@@ -0,0 +1,267 @@
|
||||
Feature: CONC3 - Garbage collection and cleanup
|
||||
As a platform operator
|
||||
I want to clean up stale sandboxes, checkpoints, and sessions
|
||||
So that disk space is reclaimed and the system stays healthy
|
||||
|
||||
# --- Retention policy defaults ---
|
||||
|
||||
Scenario: Default sandbox max age is 48 hours
|
||||
Given the default cleanup settings
|
||||
Then the sandbox max age hours should be 48
|
||||
|
||||
Scenario: Default checkpoint max per plan is 50
|
||||
Given the default cleanup settings
|
||||
Then the checkpoint max per plan should be 50
|
||||
|
||||
Scenario: Default session inactivity days is 30
|
||||
Given the default cleanup settings
|
||||
Then the session inactivity days should be 30
|
||||
|
||||
Scenario: Default log retention days is 30
|
||||
Given the default cleanup settings
|
||||
Then the log retention days should be 30
|
||||
|
||||
Scenario: Default backup retention days is 7
|
||||
Given the default cleanup settings
|
||||
Then the backup retention days should be 7
|
||||
|
||||
# --- Cleanup service: sandboxes ---
|
||||
|
||||
Scenario: Identify stale sandbox directories
|
||||
Given a sandbox directory older than the max age
|
||||
When I scan for stale sandboxes
|
||||
Then the stale sandbox should be identified
|
||||
|
||||
Scenario: Skip active sandboxes linked to running plans
|
||||
Given a sandbox directory linked to a running plan
|
||||
When I scan for stale sandboxes
|
||||
Then the active sandbox should be skipped
|
||||
And the skipped sandbox should be logged
|
||||
|
||||
Scenario: Purge a stale sandbox directory
|
||||
Given a sandbox directory older than the max age
|
||||
When I purge stale sandboxes
|
||||
Then the sandbox directory should be removed
|
||||
And the cleanup summary should report 1 sandbox removed
|
||||
|
||||
Scenario: Git worktree sandbox cleanup removes branch
|
||||
Given a stale git worktree sandbox with branch "sandbox/test-plan"
|
||||
When I purge stale sandboxes
|
||||
Then the worktree directory should be removed
|
||||
And the cleanup summary should report 1 sandbox removed
|
||||
|
||||
# --- Cleanup service: checkpoints ---
|
||||
|
||||
Scenario: Identify excess checkpoints per plan
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I scan for excess checkpoints
|
||||
Then 10 excess checkpoints should be identified
|
||||
|
||||
Scenario: Pruning keeps first and most recent checkpoints
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I prune excess checkpoints
|
||||
Then 50 checkpoints should remain
|
||||
And the first checkpoint should be preserved
|
||||
And the most recent checkpoint should be preserved
|
||||
|
||||
# --- Cleanup service: sessions ---
|
||||
|
||||
Scenario: Identify inactive sessions
|
||||
Given a session inactive for 45 days and threshold is 30
|
||||
When I scan for inactive sessions
|
||||
Then the inactive session should be identified
|
||||
|
||||
Scenario: Active sessions within threshold are skipped
|
||||
Given a session inactive for 5 days and threshold is 30
|
||||
When I scan for inactive sessions
|
||||
Then the session should not be identified
|
||||
|
||||
Scenario: Purge inactive sessions
|
||||
Given a session inactive for 45 days and threshold is 30
|
||||
When I purge inactive sessions
|
||||
Then the session should be removed
|
||||
And the cleanup summary should report 1 session removed
|
||||
|
||||
# --- Cleanup service: logs ---
|
||||
|
||||
Scenario: Identify expired log files
|
||||
Given a log file older than 30 days
|
||||
When I scan for expired logs
|
||||
Then the expired log file should be identified
|
||||
|
||||
Scenario: Fresh log files are preserved
|
||||
Given a log file created 5 days ago
|
||||
When I scan for expired logs
|
||||
Then the log file should not be identified
|
||||
|
||||
# --- Cleanup service: backups ---
|
||||
|
||||
Scenario: Identify expired backup files
|
||||
Given a backup file older than 7 days
|
||||
When I scan for expired backups
|
||||
Then the expired backup should be identified
|
||||
|
||||
# --- Dry-run mode ---
|
||||
|
||||
Scenario: Dry-run reports counts without deleting
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
When I run cleanup in dry-run mode
|
||||
Then the dry-run report should show 1 sandbox to clean
|
||||
And the dry-run report should show 1 session to clean
|
||||
And no files should be deleted
|
||||
|
||||
Scenario: Dry-run reports stale paths
|
||||
Given a sandbox directory older than the max age
|
||||
When I run cleanup in dry-run mode
|
||||
Then the dry-run report should include the sandbox path
|
||||
|
||||
# --- Full purge mode ---
|
||||
|
||||
Scenario: Full purge cleans all resource types
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
And a log file older than 30 days
|
||||
When I run cleanup with purge all
|
||||
Then the sandbox directory should be removed
|
||||
And the session should be removed
|
||||
And the log file should be removed
|
||||
|
||||
# --- Per-resource cleanup summaries ---
|
||||
|
||||
Scenario: Cleanup summary includes per-resource counts
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
When I run cleanup with purge all
|
||||
Then the cleanup summary should report 1 sandbox removed
|
||||
And the cleanup summary should report 1 session removed
|
||||
|
||||
Scenario: Cleanup summary reports zero when nothing to clean
|
||||
Given no stale resources exist
|
||||
When I run cleanup with purge all
|
||||
Then the cleanup summary should report 0 sandboxes removed
|
||||
And the cleanup summary should report 0 sessions removed
|
||||
And the cleanup summary should report 0 checkpoints removed
|
||||
|
||||
# --- Config keys for retention ---
|
||||
|
||||
Scenario: Custom retention settings override defaults
|
||||
Given cleanup settings with sandbox max age 24 hours
|
||||
Then the sandbox max age hours should be 24
|
||||
|
||||
Scenario: Cleanup schedule defaults to manual
|
||||
Given the default cleanup settings
|
||||
Then the cleanup schedule should be "manual"
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
Scenario: Cleanup handles missing directories gracefully
|
||||
Given the sandbox temp directory does not exist
|
||||
When I scan for stale sandboxes
|
||||
Then zero stale sandboxes should be found
|
||||
|
||||
Scenario: Cleanup handles empty database gracefully
|
||||
Given an empty database with no sessions or plans
|
||||
When I scan for inactive sessions
|
||||
Then zero inactive sessions should be found
|
||||
|
||||
# --- CLI command tests ---
|
||||
|
||||
Scenario: CLI scan command reports stale resources
|
||||
Given a cleanup CLI runner
|
||||
And a stale sandbox for CLI testing
|
||||
When I invoke the cleanup scan command
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Scan Results"
|
||||
|
||||
Scenario: CLI purge dry-run command shows preview
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with dry-run
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Dry-Run"
|
||||
|
||||
Scenario: CLI purge command with yes flag executes
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with yes flag
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Complete"
|
||||
|
||||
Scenario: CLI purge all command with yes flag
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with all and yes
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Complete"
|
||||
|
||||
Scenario: CLI status command shows retention policies
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup status command
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Retention Policies"
|
||||
And the cleanup CLI output should contain "Sandbox max age"
|
||||
And the cleanup CLI output should contain "Checkpoint max/plan"
|
||||
And the cleanup CLI output should contain "Session inactivity"
|
||||
And the cleanup CLI output should contain "Log retention"
|
||||
And the cleanup CLI output should contain "Backup retention"
|
||||
And the cleanup CLI output should contain "Schedule"
|
||||
|
||||
Scenario: CLI purge command without yes prompts for confirmation
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command without confirmation
|
||||
Then the CLI exit code should be 1
|
||||
|
||||
Scenario: CleanupReport as_dict returns correct structure
|
||||
When I create a cleanup report with known values
|
||||
Then the report dict should contain key "sandboxes"
|
||||
And the report dict should contain key "checkpoints"
|
||||
And the report dict should contain key "sessions"
|
||||
And the report dict should contain key "logs"
|
||||
And the report dict should contain key "backups"
|
||||
|
||||
Scenario: CleanupService rejects non-Settings argument
|
||||
When I create a cleanup service with invalid settings
|
||||
Then a TypeError should be raised
|
||||
|
||||
Scenario: Age description returns human-readable string
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 2 hour old file
|
||||
Then the age description should contain "hours old"
|
||||
|
||||
Scenario: Age description returns minutes for recent files
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 10 minute old file
|
||||
Then the age description should contain "minutes old"
|
||||
|
||||
Scenario: Age description returns days for old files
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 5 day old file
|
||||
Then the age description should contain "days old"
|
||||
|
||||
Scenario: Scan logs via full scan method
|
||||
Given a log file older than 30 days
|
||||
When I run a full scan
|
||||
Then the report should contain log stale items
|
||||
|
||||
Scenario: Scan backups via full scan method
|
||||
Given a backup file older than 7 days
|
||||
When I run a full scan
|
||||
Then the report should contain backup stale items
|
||||
|
||||
Scenario: Purge logs via purge all method
|
||||
Given a log file older than 30 days
|
||||
When I run purge all
|
||||
Then the log file should be removed
|
||||
|
||||
Scenario: Purge backups via purge all method
|
||||
Given a backup file older than 7 days
|
||||
When I run purge all
|
||||
Then the backup file should be removed
|
||||
|
||||
Scenario: Scan checkpoints via full scan method
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I run a full checkpoint scan
|
||||
Then the report should contain checkpoint stale items
|
||||
|
||||
Scenario: Purge checkpoints via purge method
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I run purge for checkpoints
|
||||
Then the report should show checkpoints removed
|
||||
@@ -0,0 +1,188 @@
|
||||
Feature: SEC5 - Secrets masking and validation
|
||||
As a security-conscious developer
|
||||
I want secrets automatically masked in logs and CLI output
|
||||
So that API keys and tokens are never leaked
|
||||
|
||||
# --- Core redaction utility ---
|
||||
|
||||
Scenario: Redact OpenAI API key pattern
|
||||
Given a string containing "sk-proj-abc123def456ghi789"
|
||||
When I redact the string
|
||||
Then the redacted value should be "***REDACTED***"
|
||||
|
||||
Scenario: Redact Anthropic API key pattern
|
||||
Given a string containing "sk-ant-api03-xyzzy1234567890abcdef"
|
||||
When I redact the string
|
||||
Then the redacted value should be "***REDACTED***"
|
||||
|
||||
Scenario: Redact token pattern
|
||||
Given a string containing "tok_01HXYZ1234567890ABCDEF"
|
||||
When I redact the string
|
||||
Then the redacted value should be "***REDACTED***"
|
||||
|
||||
Scenario: Redact generic bearer token
|
||||
Given a string containing "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz"
|
||||
When I redact the string
|
||||
Then the redacted value should contain "***REDACTED***"
|
||||
|
||||
Scenario: Non-secret strings are not redacted
|
||||
Given a string containing "hello world this is normal text"
|
||||
When I redact the string
|
||||
Then the redacted value should be "hello world this is normal text"
|
||||
|
||||
Scenario: Mixed text with embedded secret is partially redacted
|
||||
Given a string containing "My key is sk-proj-abc123def456ghi789 and name is Bob"
|
||||
When I redact the string
|
||||
Then the redacted value should contain "***REDACTED***"
|
||||
And the redacted value should contain "My key is"
|
||||
And the redacted value should contain "and name is Bob"
|
||||
|
||||
Scenario: Empty string is returned unchanged
|
||||
Given a string containing ""
|
||||
When I redact the string
|
||||
Then the redacted value should be empty
|
||||
|
||||
# --- Dict redaction ---
|
||||
|
||||
Scenario: Dict with sensitive key names are masked
|
||||
Given a dict with key "api_key" and value "sk-proj-abc123"
|
||||
When I redact the dict
|
||||
Then the key "api_key" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with password key is masked
|
||||
Given a dict with key "password" and value "mysecretpass"
|
||||
When I redact the dict
|
||||
Then the key "password" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with secret key is masked
|
||||
Given a dict with key "secret" and value "topsecretvalue"
|
||||
When I redact the dict
|
||||
Then the key "secret" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with token key is masked
|
||||
Given a dict with key "token" and value "sometoken123"
|
||||
When I redact the dict
|
||||
Then the key "token" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with credential key is masked
|
||||
Given a dict with key "credential" and value "cred-value"
|
||||
When I redact the dict
|
||||
Then the key "credential" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with non-sensitive key is not masked
|
||||
Given a dict with key "username" and value "bob"
|
||||
When I redact the dict
|
||||
Then the key "username" should have value "bob"
|
||||
|
||||
Scenario: Nested dict values are redacted recursively
|
||||
Given a nested dict with inner key "api_key" and value "sk-proj-nested"
|
||||
When I redact the dict
|
||||
Then the nested key "api_key" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict with pattern-matched value is masked regardless of key name
|
||||
Given a dict with key "config_value" and value "sk-ant-api03-somekey123"
|
||||
When I redact the dict
|
||||
Then the key "config_value" should have value "***REDACTED***"
|
||||
|
||||
Scenario: Dict redaction respects show_secrets flag
|
||||
Given a dict with key "api_key" and value "sk-proj-abc123"
|
||||
When I redact the dict with show_secrets enabled
|
||||
Then the key "api_key" should have value "sk-proj-abc123"
|
||||
|
||||
# --- Sensitive key detection ---
|
||||
|
||||
Scenario Outline: Sensitive key names are detected
|
||||
When I check if "<key>" is a sensitive key
|
||||
Then the sensitivity check should be true
|
||||
|
||||
Examples:
|
||||
| key |
|
||||
| api_key |
|
||||
| API_KEY |
|
||||
| password |
|
||||
| secret |
|
||||
| token |
|
||||
| credential |
|
||||
| openai_api_key |
|
||||
| ANTHROPIC_API_KEY |
|
||||
| access_token |
|
||||
| auth_token |
|
||||
| private_key |
|
||||
|
||||
Scenario Outline: Non-sensitive key names are not flagged
|
||||
When I check if "<key>" is a sensitive key
|
||||
Then the sensitivity check should be false
|
||||
|
||||
Examples:
|
||||
| key |
|
||||
| username |
|
||||
| email |
|
||||
| name |
|
||||
| description |
|
||||
| project_id |
|
||||
| token_count |
|
||||
|
||||
# --- Database URL masking ---
|
||||
|
||||
Scenario: SQLite URL is not masked
|
||||
Given a database URL "sqlite:///path/to/db.sqlite"
|
||||
When I mask the database URL
|
||||
Then the masked URL should be "sqlite:///path/to/db.sqlite"
|
||||
|
||||
Scenario: PostgreSQL URL with credentials is masked
|
||||
Given a database URL "postgresql://admin:s3cret@localhost:5432/mydb"
|
||||
When I mask the database URL
|
||||
Then the masked URL should contain "***"
|
||||
And the masked URL should contain "localhost"
|
||||
And the masked URL should not contain "s3cret"
|
||||
|
||||
# --- Global show_secrets flag ---
|
||||
|
||||
Scenario: Global show_secrets defaults to false
|
||||
When I check the global show_secrets flag
|
||||
Then the flag should be false
|
||||
|
||||
Scenario: Global show_secrets can be toggled
|
||||
When I set the global show_secrets flag to true
|
||||
Then the flag should be true
|
||||
When I set the global show_secrets flag to false
|
||||
Then the flag should be false
|
||||
|
||||
# --- structlog processor ---
|
||||
|
||||
Scenario: Structlog masking processor redacts event dict values
|
||||
Given a structlog event dict with key "api_key" and value "sk-proj-secret123"
|
||||
When the masking processor is applied
|
||||
Then the event dict key "api_key" should be "***REDACTED***"
|
||||
|
||||
Scenario: Structlog masking processor redacts event message
|
||||
Given a structlog event dict with event "Failed with key sk-ant-api03-xyz"
|
||||
When the masking processor is applied
|
||||
Then the event message should contain "***REDACTED***"
|
||||
And the event message should not contain "sk-ant-api03-xyz"
|
||||
|
||||
Scenario: Structlog masking processor passes non-secret events
|
||||
Given a structlog event dict with event "User logged in successfully"
|
||||
When the masking processor is applied
|
||||
Then the event message should be "User logged in successfully"
|
||||
|
||||
# --- CLI error detail redaction ---
|
||||
|
||||
Scenario: Error details with secret values are redacted
|
||||
Given error details with key "api_key" and value "sk-proj-leaked"
|
||||
When I redact the error details for CLI display
|
||||
Then the redacted details key "api_key" should be "***REDACTED***"
|
||||
|
||||
Scenario: Error details with non-secret values are preserved
|
||||
Given error details with key "plan_id" and value "01HXR123"
|
||||
When I redact the error details for CLI display
|
||||
Then the redacted details key "plan_id" should be "01HXR123"
|
||||
|
||||
# --- Secret pattern registration ---
|
||||
|
||||
Scenario: Custom secret pattern can be registered
|
||||
Given the default secret patterns
|
||||
When I register a custom pattern "custom_[a-z0-9]{16}"
|
||||
And I redact the string "my custom_abcdef1234567890 key"
|
||||
Then the redacted value should contain "***REDACTED***"
|
||||
And the redacted value should not contain "custom_abcdef1234567890"
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Step definitions for CONC3 cleanup CLI commands and edge cases.
|
||||
|
||||
Exercises the Typer CLI commands (scan, purge, status) and
|
||||
service-level edge cases for coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.cleanup_service import (
|
||||
CleanupReport,
|
||||
CleanupService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_settings(**overrides: object) -> Settings:
|
||||
"""Create a Settings instance with optional field overrides.
|
||||
|
||||
Uses ``model_copy(update=...)`` so that Pydantic validators
|
||||
(including ``ge=`` bounds on retention fields) are enforced.
|
||||
"""
|
||||
Settings._instance = None
|
||||
base = Settings()
|
||||
if overrides:
|
||||
return base.model_copy(update=overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _create_sandbox_dir(
|
||||
prefix: str = "ca-sandbox-testplan-",
|
||||
age_hours: float = 72,
|
||||
) -> Path:
|
||||
"""Create a fake sandbox temp directory with a given age."""
|
||||
d = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
old_time = time.time() - (age_hours * 3600)
|
||||
os.utime(d, (old_time, old_time))
|
||||
return d
|
||||
|
||||
|
||||
# ── CLI command tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a cleanup CLI runner")
|
||||
def step_cli_runner(context: Context) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
context.cli_runner = CliRunner()
|
||||
|
||||
|
||||
@given("a stale sandbox for CLI testing")
|
||||
def step_stale_sandbox_cli(context: Context) -> None:
|
||||
import shutil
|
||||
|
||||
context.cli_sandbox_dir = _create_sandbox_dir(age_hours=72)
|
||||
|
||||
if not hasattr(context, "_temp_paths"):
|
||||
context._temp_paths = []
|
||||
context._temp_paths.append(context.cli_sandbox_dir)
|
||||
|
||||
def _cleanup() -> None:
|
||||
for p in getattr(context, "_temp_paths", []):
|
||||
if p.exists():
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
if _cleanup not in context._cleanup_handlers:
|
||||
context._cleanup_handlers.append(_cleanup)
|
||||
|
||||
|
||||
@when("I invoke the cleanup scan command")
|
||||
def step_invoke_scan(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["scan"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with dry-run")
|
||||
def step_invoke_purge_dry(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--dry-run"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with yes flag")
|
||||
def step_invoke_purge_yes(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--yes"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with all and yes")
|
||||
def step_invoke_purge_all_yes(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(
|
||||
cleanup_app, ["purge", "--all", "--yes"]
|
||||
)
|
||||
|
||||
|
||||
@when("I invoke the cleanup status command")
|
||||
def step_invoke_status(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["status"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command without confirmation")
|
||||
def step_invoke_purge_no_confirm(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge"], input="n\n")
|
||||
|
||||
|
||||
@then("the CLI exit code should be {code:d}")
|
||||
def step_cli_exit_code(context: Context, code: int) -> None:
|
||||
assert context.cli_result.exit_code == code, (
|
||||
f"Expected exit code {code}, got {context.cli_result.exit_code}. "
|
||||
f"Output: {context.cli_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cleanup CLI output should contain "{text}"')
|
||||
def step_cleanup_cli_output_contains(context: Context, text: str) -> None:
|
||||
assert text in context.cli_result.output, (
|
||||
f"Expected '{text}' in output, got: {context.cli_result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ── CleanupReport and CleanupService edge case tests ─────────────
|
||||
|
||||
|
||||
@when("I create a cleanup report with known values")
|
||||
def step_create_report(context: Context) -> None:
|
||||
report = CleanupReport(dry_run=True)
|
||||
report.sandboxes.scanned = 5
|
||||
report.sandboxes.removed = 2
|
||||
report.checkpoints.scanned = 100
|
||||
context.report_dict = report.as_dict()
|
||||
|
||||
|
||||
@then('the report dict should contain key "{key}"')
|
||||
def step_report_dict_key(context: Context, key: str) -> None:
|
||||
assert key in context.report_dict, (
|
||||
f"Expected key '{key}' in report dict, got: {list(context.report_dict.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@when("I create a cleanup service with invalid settings")
|
||||
def step_invalid_settings(context: Context) -> None:
|
||||
try:
|
||||
CleanupService(settings="not-a-settings") # type: ignore[arg-type]
|
||||
context.type_error_raised = False
|
||||
except TypeError:
|
||||
context.type_error_raised = True
|
||||
|
||||
|
||||
@then("a TypeError should be raised")
|
||||
def step_type_error(context: Context) -> None:
|
||||
assert context.type_error_raised, "Expected TypeError to be raised"
|
||||
|
||||
|
||||
@given("a cleanup service for age description testing")
|
||||
def step_age_service(context: Context) -> None:
|
||||
context.settings = _make_settings()
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I check the age description for a {hours:d} hour old file")
|
||||
def step_age_hours(context: Context, hours: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (hours * 3600)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@when("I check the age description for a {minutes:d} minute old file")
|
||||
def step_age_minutes(context: Context, minutes: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (minutes * 60)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@when("I check the age description for a {days:d} day old file")
|
||||
def step_age_days(context: Context, days: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (days * 86400)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@then('the age description should contain "{text}"')
|
||||
def step_age_contains(context: Context, text: str) -> None:
|
||||
assert text in context.age_desc, (
|
||||
f"Expected '{text}' in age description, got: {context.age_desc}"
|
||||
)
|
||||
|
||||
|
||||
# ── Full scan/purge integration via service ──────────────────────
|
||||
|
||||
|
||||
@when("I run a full scan")
|
||||
def step_full_scan(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the report should contain log stale items")
|
||||
def step_log_stale_items(context: Context) -> None:
|
||||
log_items = [i for i in context.report.stale_items if i.resource_type == "log"]
|
||||
assert len(log_items) >= 1, f"Expected >= 1 log stale item, found {len(log_items)}"
|
||||
|
||||
|
||||
@then("the report should contain backup stale items")
|
||||
def step_backup_stale_items(context: Context) -> None:
|
||||
backup_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "backup"
|
||||
]
|
||||
assert len(backup_items) >= 1, (
|
||||
f"Expected >= 1 backup stale item, found {len(backup_items)}"
|
||||
)
|
||||
|
||||
|
||||
@when("I run purge all")
|
||||
def step_run_purge_all(context: Context) -> None:
|
||||
context.report = context.service.purge(purge_all=True)
|
||||
|
||||
|
||||
@then("the backup file should be removed")
|
||||
def step_backup_removed(context: Context) -> None:
|
||||
assert not context.backup_file.exists(), (
|
||||
f"Backup file {context.backup_file} still exists"
|
||||
)
|
||||
|
||||
|
||||
@when("I run a full checkpoint scan")
|
||||
def step_full_checkpoint_scan(context: Context) -> None:
|
||||
context.service._get_checkpoint_base_dir = lambda: context.checkpoint_dir.parent
|
||||
plan_dir = context.checkpoint_dir.parent / "test-plan"
|
||||
if not plan_dir.exists():
|
||||
context.checkpoint_dir.rename(plan_dir)
|
||||
context.checkpoint_dir = plan_dir
|
||||
context.service._get_checkpoint_base_dir = lambda: plan_dir.parent
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the report should contain checkpoint stale items")
|
||||
def step_checkpoint_stale_items(context: Context) -> None:
|
||||
cp_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "checkpoint"
|
||||
]
|
||||
assert len(cp_items) >= 1, (
|
||||
f"Expected >= 1 checkpoint stale item, found {len(cp_items)}"
|
||||
)
|
||||
|
||||
|
||||
@when("I run purge for checkpoints")
|
||||
def step_purge_checkpoints_full(context: Context) -> None:
|
||||
plan_dir = context.checkpoint_dir
|
||||
context.service._get_checkpoint_base_dir = lambda: plan_dir.parent
|
||||
target = plan_dir.parent / "test-plan-purge"
|
||||
if not target.exists():
|
||||
plan_dir.rename(target)
|
||||
context.checkpoint_dir = target
|
||||
context.service._get_checkpoint_base_dir = lambda: target.parent
|
||||
context.report = context.service.purge()
|
||||
|
||||
|
||||
@then("the report should show checkpoints removed")
|
||||
def step_checkpoints_removed_report(context: Context) -> None:
|
||||
assert context.report.checkpoints.removed >= 1, (
|
||||
f"Expected >= 1 checkpoint removed, got {context.report.checkpoints.removed}"
|
||||
)
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Step definitions for CONC3 - Garbage collection and cleanup.
|
||||
|
||||
Tests the cleanup service, retention policies, dry-run mode,
|
||||
purge mode, and per-resource cleanup summaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.cleanup_service import (
|
||||
CleanupReport,
|
||||
CleanupService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_settings(**overrides: object) -> Settings:
|
||||
"""Create a Settings instance with optional field overrides.
|
||||
|
||||
Uses ``model_copy(update=...)`` so that Pydantic validators
|
||||
(including ``ge=`` bounds on retention fields) are enforced.
|
||||
"""
|
||||
Settings._instance = None
|
||||
base = Settings()
|
||||
if overrides:
|
||||
return base.model_copy(update=overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _create_sandbox_dir(
|
||||
prefix: str = "ca-sandbox-testplan-",
|
||||
age_hours: float = 72,
|
||||
) -> Path:
|
||||
"""Create a fake sandbox temp directory with a given age."""
|
||||
d = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
# Set mtime to simulate age
|
||||
old_time = time.time() - (age_hours * 3600)
|
||||
os.utime(d, (old_time, old_time))
|
||||
return d
|
||||
|
||||
|
||||
def _create_checkpoint_files(
|
||||
plan_dir: Path,
|
||||
count: int,
|
||||
) -> list[Path]:
|
||||
"""Create N checkpoint JSON files in plan_dir."""
|
||||
plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
files: list[Path] = []
|
||||
for i in range(count):
|
||||
f = plan_dir / f"checkpoint_{i:04d}.json"
|
||||
f.write_text(f'{{"step": {i}}}')
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
# ── Cleanup after each scenario ──────────────────────────────────
|
||||
|
||||
# Paths created during scenarios, cleaned in after_scenario via
|
||||
# context._cleanup_handlers (set by the Behave environment.py).
|
||||
|
||||
|
||||
def _register_cleanup(context: Context, path: Path) -> None:
|
||||
"""Register a path for cleanup after the scenario."""
|
||||
if not hasattr(context, "_temp_paths"):
|
||||
context._temp_paths = []
|
||||
context._temp_paths.append(path)
|
||||
|
||||
def _cleanup() -> None:
|
||||
for p in getattr(context, "_temp_paths", []):
|
||||
if p.exists():
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
if _cleanup not in context._cleanup_handlers:
|
||||
context._cleanup_handlers.append(_cleanup)
|
||||
|
||||
|
||||
# ── Retention policy defaults ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("the default cleanup settings")
|
||||
def step_default_settings(context: Context) -> None:
|
||||
context.settings = _make_settings()
|
||||
|
||||
|
||||
@then("the sandbox max age hours should be {hours:d}")
|
||||
def step_sandbox_max_age(context: Context, hours: int) -> None:
|
||||
assert context.settings.cleanup_sandbox_max_age_hours == hours, (
|
||||
f"Expected {hours}, got {context.settings.cleanup_sandbox_max_age_hours}"
|
||||
)
|
||||
|
||||
|
||||
@then("the checkpoint max per plan should be {count:d}")
|
||||
def step_checkpoint_max(context: Context, count: int) -> None:
|
||||
assert context.settings.cleanup_checkpoint_max_per_plan == count, (
|
||||
f"Expected {count}, got {context.settings.cleanup_checkpoint_max_per_plan}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session inactivity days should be {days:d}")
|
||||
def step_session_inactivity(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_session_inactivity_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_session_inactivity_days}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log retention days should be {days:d}")
|
||||
def step_log_retention(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_log_retention_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_log_retention_days}"
|
||||
)
|
||||
|
||||
|
||||
@then("the backup retention days should be {days:d}")
|
||||
def step_backup_retention(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_backup_retention_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_backup_retention_days}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cleanup schedule should be "{schedule}"')
|
||||
def step_cleanup_schedule(context: Context, schedule: str) -> None:
|
||||
assert context.settings.cleanup_schedule == schedule, (
|
||||
f"Expected '{schedule}', got '{context.settings.cleanup_schedule}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Sandbox cleanup scenarios ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("a sandbox directory older than the max age")
|
||||
def step_stale_sandbox(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(age_hours=72)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@given("a sandbox directory linked to a running plan")
|
||||
def step_active_sandbox(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(
|
||||
prefix="ca-sandbox-activeplan-",
|
||||
age_hours=72,
|
||||
)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset({"activeplan"}),
|
||||
)
|
||||
|
||||
|
||||
@given('a stale git worktree sandbox with branch "{branch}"')
|
||||
def step_stale_worktree(context: Context, branch: str) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(
|
||||
prefix="ca-sandbox-test-plan-",
|
||||
age_hours=72,
|
||||
)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for stale sandboxes")
|
||||
def step_scan_sandboxes(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@when("I purge stale sandboxes")
|
||||
def step_purge_sandboxes(context: Context) -> None:
|
||||
context.report = context.service.purge()
|
||||
|
||||
|
||||
@then("the stale sandbox should be identified")
|
||||
def step_stale_identified(context: Context) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) >= 1, (
|
||||
f"Expected at least 1 stale sandbox, found {len(sandbox_items)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the active sandbox should be skipped")
|
||||
def step_active_skipped(context: Context) -> None:
|
||||
assert context.report.sandboxes.skipped >= 1, (
|
||||
f"Expected skipped >= 1, got {context.report.sandboxes.skipped}"
|
||||
)
|
||||
|
||||
|
||||
@then("the skipped sandbox should be logged")
|
||||
def step_skipped_logged(context: Context) -> None:
|
||||
assert len(context.report.sandboxes.skipped_reasons) >= 1, (
|
||||
"Expected at least one skip reason logged"
|
||||
)
|
||||
|
||||
|
||||
@then("the sandbox directory should be removed")
|
||||
def step_sandbox_removed(context: Context) -> None:
|
||||
assert not context.sandbox_dir.exists(), (
|
||||
f"Sandbox dir {context.sandbox_dir} still exists"
|
||||
)
|
||||
|
||||
|
||||
@then("the worktree directory should be removed")
|
||||
def step_worktree_removed(context: Context) -> None:
|
||||
assert not context.sandbox_dir.exists(), (
|
||||
f"Worktree dir {context.sandbox_dir} still exists"
|
||||
)
|
||||
|
||||
|
||||
@then("the cleanup summary should report {count:d} sandbox removed")
|
||||
def step_sandbox_summary(context: Context, count: int) -> None:
|
||||
assert context.report.sandboxes.removed >= count, (
|
||||
f"Expected >= {count} removed, got {context.report.sandboxes.removed}"
|
||||
)
|
||||
|
||||
|
||||
# ── Checkpoint cleanup scenarios ──────────────────────────────────
|
||||
|
||||
|
||||
@given("a plan with {total:d} checkpoints and max per plan is {max_cp:d}")
|
||||
def step_excess_checkpoints(context: Context, total: int, max_cp: int) -> None:
|
||||
context.settings = _make_settings(cleanup_checkpoint_max_per_plan=max_cp)
|
||||
context.checkpoint_dir = Path(tempfile.mkdtemp(prefix="ca-checkpoint-test-"))
|
||||
_register_cleanup(context, context.checkpoint_dir)
|
||||
context.checkpoint_files = _create_checkpoint_files(
|
||||
context.checkpoint_dir,
|
||||
total,
|
||||
)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for excess checkpoints")
|
||||
def step_scan_checkpoints(context: Context) -> None:
|
||||
context.excess = context.service.scan_checkpoints_for_plan(
|
||||
context.checkpoint_dir,
|
||||
)
|
||||
|
||||
|
||||
@when("I prune excess checkpoints")
|
||||
def step_prune_checkpoints(context: Context) -> None:
|
||||
context.removed_count = context.service.prune_checkpoints_for_plan(
|
||||
context.checkpoint_dir,
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} excess checkpoints should be identified")
|
||||
def step_excess_identified(context: Context, count: int) -> None:
|
||||
assert len(context.excess) == count, (
|
||||
f"Expected {count} excess, found {len(context.excess)}"
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} checkpoints should remain")
|
||||
def step_checkpoints_remain(context: Context, count: int) -> None:
|
||||
remaining = list(context.checkpoint_dir.glob("checkpoint_*.json"))
|
||||
assert len(remaining) == count, (
|
||||
f"Expected {count} remaining, found {len(remaining)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the first checkpoint should be preserved")
|
||||
def step_first_preserved(context: Context) -> None:
|
||||
first = context.checkpoint_files[0]
|
||||
assert first.exists(), f"First checkpoint {first} was deleted"
|
||||
|
||||
|
||||
@then("the most recent checkpoint should be preserved")
|
||||
def step_last_preserved(context: Context) -> None:
|
||||
last = context.checkpoint_files[-1]
|
||||
assert last.exists(), f"Most recent checkpoint {last} was deleted"
|
||||
|
||||
|
||||
# ── Session cleanup scenarios ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("a session inactive for {days:d} days and threshold is {threshold:d}")
|
||||
def step_inactive_session(context: Context, days: int, threshold: int) -> None:
|
||||
context.settings = _make_settings(cleanup_session_inactivity_days=threshold)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
updated = datetime.now(tz=UTC) - timedelta(days=days)
|
||||
context.test_sessions = [
|
||||
{"session_id": "sess-001", "updated_at": updated},
|
||||
]
|
||||
|
||||
|
||||
@when("I scan for inactive sessions")
|
||||
def step_scan_sessions(context: Context) -> None:
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
||||
|
||||
|
||||
@when("I purge inactive sessions")
|
||||
def step_purge_sessions(context: Context) -> None:
|
||||
# For the unit test we simulate by scanning + counting
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
||||
context.report = CleanupReport(dry_run=False)
|
||||
context.report.sessions.removed = len(context.inactive_sessions)
|
||||
|
||||
|
||||
@then("the inactive session should be identified")
|
||||
def step_inactive_identified(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) >= 1, (
|
||||
f"Expected >= 1 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session should not be identified")
|
||||
def step_session_not_identified(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) == 0, (
|
||||
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session should be removed")
|
||||
def step_session_removed(context: Context) -> None:
|
||||
assert context.report.sessions.removed >= 1, (
|
||||
f"Expected >= 1 removed, got {context.report.sessions.removed}"
|
||||
)
|
||||
|
||||
|
||||
@then("the cleanup summary should report {count:d} session removed")
|
||||
def step_session_summary(context: Context, count: int) -> None:
|
||||
assert context.report.sessions.removed == count, (
|
||||
f"Expected {count} sessions removed, got {context.report.sessions.removed}"
|
||||
)
|
||||
|
||||
|
||||
# ── Log cleanup scenarios ─────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a log file older than {days:d} days")
|
||||
def step_old_log(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_log_retention_days=days,
|
||||
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
||||
)
|
||||
_register_cleanup(context, context.settings.log_dir)
|
||||
context.log_file = context.settings.log_dir / "old.log"
|
||||
context.log_file.write_text("old log content")
|
||||
old_time = time.time() - ((days + 5) * 86400)
|
||||
os.utime(context.log_file, (old_time, old_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@given("a log file created {days:d} days ago")
|
||||
def step_fresh_log(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_log_retention_days=30,
|
||||
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
||||
)
|
||||
_register_cleanup(context, context.settings.log_dir)
|
||||
context.log_file = context.settings.log_dir / "fresh.log"
|
||||
context.log_file.write_text("fresh log content")
|
||||
recent_time = time.time() - (days * 86400)
|
||||
os.utime(context.log_file, (recent_time, recent_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for expired logs")
|
||||
def step_scan_logs(context: Context) -> None:
|
||||
context.expired_logs = context.service.scan_expired_files(
|
||||
context.settings.log_dir,
|
||||
context.settings.cleanup_log_retention_days,
|
||||
pattern="*.log",
|
||||
)
|
||||
|
||||
|
||||
@then("the expired log file should be identified")
|
||||
def step_expired_log_found(context: Context) -> None:
|
||||
assert len(context.expired_logs) >= 1, (
|
||||
f"Expected >= 1 expired log, found {len(context.expired_logs)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log file should not be identified")
|
||||
def step_log_not_found(context: Context) -> None:
|
||||
assert len(context.expired_logs) == 0, (
|
||||
f"Expected 0 expired logs, found {len(context.expired_logs)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log file should be removed")
|
||||
def step_log_removed(context: Context) -> None:
|
||||
assert not context.log_file.exists(), f"Log file {context.log_file} still exists"
|
||||
|
||||
|
||||
# ── Backup cleanup scenarios ──────────────────────────────────────
|
||||
|
||||
|
||||
@given("a backup file older than {days:d} days")
|
||||
def step_old_backup(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_backup_retention_days=days,
|
||||
data_dir=Path(tempfile.mkdtemp(prefix="ca-data-test-")),
|
||||
)
|
||||
backup_dir = context.settings.data_dir / "backups"
|
||||
backup_dir.mkdir(parents=True)
|
||||
_register_cleanup(context, context.settings.data_dir)
|
||||
context.backup_file = backup_dir / "backup-20250101.tar.gz"
|
||||
context.backup_file.write_text("backup data")
|
||||
old_time = time.time() - ((days + 5) * 86400)
|
||||
os.utime(context.backup_file, (old_time, old_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for expired backups")
|
||||
def step_scan_backups(context: Context) -> None:
|
||||
backup_dir = context.service._get_backup_dir()
|
||||
context.expired_backups = context.service.scan_expired_files(
|
||||
backup_dir,
|
||||
context.settings.cleanup_backup_retention_days,
|
||||
pattern="*",
|
||||
)
|
||||
|
||||
|
||||
@then("the expired backup should be identified")
|
||||
def step_expired_backup_found(context: Context) -> None:
|
||||
assert len(context.expired_backups) >= 1, (
|
||||
f"Expected >= 1 expired backup, found {len(context.expired_backups)}"
|
||||
)
|
||||
|
||||
|
||||
# ── Dry-run mode ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I run cleanup in dry-run mode")
|
||||
def step_dry_run(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the dry-run report should show {count:d} sandbox to clean")
|
||||
def step_dry_run_sandboxes(context: Context, count: int) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) == count, (
|
||||
f"Expected {count} sandbox items, found {len(sandbox_items)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the dry-run report should show {count:d} session to clean")
|
||||
def step_dry_run_sessions(context: Context, count: int) -> None:
|
||||
# Sessions are identified externally; we track via inactive_sessions
|
||||
inactive = getattr(context, "inactive_sessions", None)
|
||||
if inactive is None:
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
inactive = context.service.scan_inactive_sessions(sessions)
|
||||
assert len(inactive) == count, (
|
||||
f"Expected {count} session items, found {len(inactive)}"
|
||||
)
|
||||
|
||||
|
||||
@then("no files should be deleted")
|
||||
def step_no_files_deleted(context: Context) -> None:
|
||||
# In dry-run, the sandbox_dir should still exist
|
||||
if hasattr(context, "sandbox_dir"):
|
||||
assert context.sandbox_dir.exists(), "Sandbox was deleted in dry-run mode"
|
||||
|
||||
|
||||
@then("the dry-run report should include the sandbox path")
|
||||
def step_dry_run_path(context: Context) -> None:
|
||||
paths = [i.path for i in context.report.stale_items]
|
||||
assert str(context.sandbox_dir) in paths, (
|
||||
f"Expected {context.sandbox_dir} in stale paths, got {paths}"
|
||||
)
|
||||
|
||||
|
||||
# ── Full purge mode ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I run cleanup with purge all")
|
||||
def step_purge_all(context: Context) -> None:
|
||||
context.report = context.service.purge(purge_all=True)
|
||||
# For session cleanup, simulate removal count
|
||||
if hasattr(context, "test_sessions"):
|
||||
inactive = context.service.scan_inactive_sessions(context.test_sessions)
|
||||
context.report.sessions.removed += len(inactive)
|
||||
|
||||
|
||||
# ── Per-resource summary ──────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 sandboxes removed")
|
||||
def step_zero_sandboxes(context: Context) -> None:
|
||||
assert context.report.sandboxes.removed == 0
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 sessions removed")
|
||||
def step_zero_sessions(context: Context) -> None:
|
||||
assert context.report.sessions.removed == 0
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 checkpoints removed")
|
||||
def step_zero_checkpoints(context: Context) -> None:
|
||||
assert context.report.checkpoints.removed == 0
|
||||
|
||||
|
||||
# ── Custom config ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("cleanup settings with sandbox max age {hours:d} hours")
|
||||
def step_custom_settings(context: Context, hours: int) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=hours)
|
||||
|
||||
|
||||
# ── Edge cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("the sandbox temp directory does not exist")
|
||||
def step_missing_temp(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
# Patch _get_sandbox_dirs to return empty (non-existent temp)
|
||||
context.service._get_sandbox_dirs = lambda: []
|
||||
|
||||
|
||||
@then("zero stale sandboxes should be found")
|
||||
def step_zero_stale(context: Context) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) == 0, f"Expected 0 stale, found {len(sandbox_items)}"
|
||||
|
||||
|
||||
@given("an empty database with no sessions or plans")
|
||||
def step_empty_db(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_session_inactivity_days=30)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
context.test_sessions = []
|
||||
|
||||
|
||||
@then("zero inactive sessions should be found")
|
||||
def step_zero_sessions_found(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) == 0, (
|
||||
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
# ── Shared steps that work across scenarios ───────────────────────
|
||||
|
||||
|
||||
@given("no stale resources exist")
|
||||
def step_no_stale(context: Context) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_sandbox_max_age_hours=48,
|
||||
cleanup_session_inactivity_days=30,
|
||||
cleanup_checkpoint_max_per_plan=50,
|
||||
)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
# Ensure no temp sandbox dirs match (patch to empty)
|
||||
context.service._get_sandbox_dirs = lambda: []
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Step definitions for SEC5 - Secrets masking and validation.
|
||||
|
||||
Tests the centralized redaction utility, structlog masking processor,
|
||||
database URL masking, the global show_secrets flag, CLI error detail
|
||||
redaction, and custom pattern registration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.shared.redaction import (
|
||||
get_show_secrets,
|
||||
is_sensitive_key,
|
||||
mask_database_url,
|
||||
redact_dict,
|
||||
redact_value,
|
||||
register_pattern,
|
||||
secrets_masking_processor,
|
||||
set_show_secrets,
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _reset_show_secrets() -> None:
|
||||
"""Reset the global show_secrets flag to its default (False)."""
|
||||
set_show_secrets(False)
|
||||
|
||||
|
||||
# ── Core redaction: Given / When / Then ───────────────────────────
|
||||
|
||||
|
||||
@given('a string containing "{value}"')
|
||||
def step_given_string(context: Context, value: str) -> None:
|
||||
context.input_string = value
|
||||
|
||||
|
||||
@given('a string containing ""')
|
||||
def step_given_empty_string(context: Context) -> None:
|
||||
context.input_string = ""
|
||||
|
||||
|
||||
@when("I redact the string")
|
||||
def step_redact_string(context: Context) -> None:
|
||||
context.redacted_result = redact_value(context.input_string)
|
||||
|
||||
|
||||
@then('the redacted value should be "{expected}"')
|
||||
def step_redacted_value_should_be(context: Context, expected: str) -> None:
|
||||
assert context.redacted_result == expected, (
|
||||
f"Expected '{expected}', got '{context.redacted_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the redacted value should be empty")
|
||||
def step_redacted_value_should_be_empty(context: Context) -> None:
|
||||
assert context.redacted_result == "", (
|
||||
f"Expected empty string, got '{context.redacted_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the redacted value should contain "{substring}"')
|
||||
def step_redacted_value_should_contain(context: Context, substring: str) -> None:
|
||||
assert substring in context.redacted_result, (
|
||||
f"Expected '{substring}' in '{context.redacted_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the redacted value should not contain "{substring}"')
|
||||
def step_redacted_value_should_not_contain(
|
||||
context: Context,
|
||||
substring: str,
|
||||
) -> None:
|
||||
assert substring not in context.redacted_result, (
|
||||
f"Did not expect '{substring}' in '{context.redacted_result}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Dict redaction ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a dict with key "{key}" and value "{value}"')
|
||||
def step_given_dict(context: Context, key: str, value: str) -> None:
|
||||
context.input_dict = {key: value}
|
||||
|
||||
|
||||
@given('a nested dict with inner key "{key}" and value "{value}"')
|
||||
def step_given_nested_dict(context: Context, key: str, value: str) -> None:
|
||||
context.input_dict = {"outer": {key: value}}
|
||||
|
||||
|
||||
@when("I redact the dict")
|
||||
def step_redact_dict(context: Context) -> None:
|
||||
context.redacted_dict = redact_dict(context.input_dict)
|
||||
|
||||
|
||||
@when("I redact the dict with show_secrets enabled")
|
||||
def step_redact_dict_show_secrets(context: Context) -> None:
|
||||
context.redacted_dict = redact_dict(context.input_dict, show_secrets=True)
|
||||
|
||||
|
||||
@then('the key "{key}" should have value "{expected}"')
|
||||
def step_key_should_have_value(context: Context, key: str, expected: str) -> None:
|
||||
actual = context.redacted_dict[key]
|
||||
assert actual == expected, f"Key '{key}': expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the nested key "{key}" should have value "{expected}"')
|
||||
def step_nested_key_should_have_value(
|
||||
context: Context,
|
||||
key: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
actual = context.redacted_dict["outer"][key]
|
||||
assert actual == expected, (
|
||||
f"Nested key '{key}': expected '{expected}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Sensitive key detection ───────────────────────────────────────
|
||||
|
||||
|
||||
@when('I check if "{key}" is a sensitive key')
|
||||
def step_check_sensitive_key(context: Context, key: str) -> None:
|
||||
context.sensitive_result = is_sensitive_key(key)
|
||||
|
||||
|
||||
@then("the sensitivity check should be true")
|
||||
def step_sensitivity_true(context: Context) -> None:
|
||||
assert context.sensitive_result is True, "Expected True, got False"
|
||||
|
||||
|
||||
@then("the sensitivity check should be false")
|
||||
def step_sensitivity_false(context: Context) -> None:
|
||||
assert context.sensitive_result is False, "Expected False, got True"
|
||||
|
||||
|
||||
# ── Database URL masking ─────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a database URL "{url}"')
|
||||
def step_given_database_url(context: Context, url: str) -> None:
|
||||
context.database_url = url
|
||||
|
||||
|
||||
@when("I mask the database URL")
|
||||
def step_mask_database_url(context: Context) -> None:
|
||||
context.masked_url = mask_database_url(context.database_url)
|
||||
|
||||
|
||||
@then('the masked URL should be "{expected}"')
|
||||
def step_masked_url_should_be(context: Context, expected: str) -> None:
|
||||
assert context.masked_url == expected, (
|
||||
f"Expected '{expected}', got '{context.masked_url}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the masked URL should contain "{substring}"')
|
||||
def step_masked_url_should_contain(context: Context, substring: str) -> None:
|
||||
assert substring in context.masked_url, (
|
||||
f"Expected '{substring}' in '{context.masked_url}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the masked URL should not contain "{substring}"')
|
||||
def step_masked_url_should_not_contain(
|
||||
context: Context,
|
||||
substring: str,
|
||||
) -> None:
|
||||
assert substring not in context.masked_url, (
|
||||
f"Did not expect '{substring}' in '{context.masked_url}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Global show_secrets flag ─────────────────────────────────────
|
||||
|
||||
|
||||
@when("I check the global show_secrets flag")
|
||||
def step_check_show_secrets(context: Context) -> None:
|
||||
# Ensure we start from clean state
|
||||
_reset_show_secrets()
|
||||
context._cleanup_handlers.append(_reset_show_secrets)
|
||||
context.show_secrets_flag = get_show_secrets()
|
||||
|
||||
|
||||
@when("I set the global show_secrets flag to true")
|
||||
def step_set_show_secrets_true(context: Context) -> None:
|
||||
set_show_secrets(True)
|
||||
if _reset_show_secrets not in context._cleanup_handlers:
|
||||
context._cleanup_handlers.append(_reset_show_secrets)
|
||||
context.show_secrets_flag = get_show_secrets()
|
||||
|
||||
|
||||
@when("I set the global show_secrets flag to false")
|
||||
def step_set_show_secrets_false(context: Context) -> None:
|
||||
set_show_secrets(False)
|
||||
context.show_secrets_flag = get_show_secrets()
|
||||
|
||||
|
||||
@then("the flag should be false")
|
||||
def step_flag_false(context: Context) -> None:
|
||||
assert context.show_secrets_flag is False, "Expected show_secrets to be False"
|
||||
|
||||
|
||||
@then("the flag should be true")
|
||||
def step_flag_true(context: Context) -> None:
|
||||
assert context.show_secrets_flag is True, "Expected show_secrets to be True"
|
||||
|
||||
|
||||
# ── structlog processor ──────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a structlog event dict with key "{key}" and value "{value}"')
|
||||
def step_given_structlog_event_key(
|
||||
context: Context,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
# Ensure show_secrets is off for processor tests
|
||||
_reset_show_secrets()
|
||||
context._cleanup_handlers.append(_reset_show_secrets)
|
||||
context.event_dict = {key: value}
|
||||
|
||||
|
||||
@given('a structlog event dict with event "{event}"')
|
||||
def step_given_structlog_event_msg(context: Context, event: str) -> None:
|
||||
_reset_show_secrets()
|
||||
context._cleanup_handlers.append(_reset_show_secrets)
|
||||
context.event_dict = {"event": event}
|
||||
|
||||
|
||||
@when("the masking processor is applied")
|
||||
def step_apply_masking_processor(context: Context) -> None:
|
||||
context.processed_event = secrets_masking_processor(
|
||||
logger=None,
|
||||
method_name="info",
|
||||
event_dict=dict(context.event_dict),
|
||||
)
|
||||
|
||||
|
||||
@then('the event dict key "{key}" should be "{expected}"')
|
||||
def step_event_key_should_be(context: Context, key: str, expected: str) -> None:
|
||||
actual = context.processed_event[key]
|
||||
assert actual == expected, (
|
||||
f"Event key '{key}': expected '{expected}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the event message should contain "{substring}"')
|
||||
def step_event_message_should_contain(
|
||||
context: Context,
|
||||
substring: str,
|
||||
) -> None:
|
||||
msg = context.processed_event["event"]
|
||||
assert substring in msg, f"Expected '{substring}' in event message '{msg}'"
|
||||
|
||||
|
||||
@then('the event message should not contain "{substring}"')
|
||||
def step_event_message_should_not_contain(
|
||||
context: Context,
|
||||
substring: str,
|
||||
) -> None:
|
||||
msg = context.processed_event["event"]
|
||||
assert substring not in msg, (
|
||||
f"Did not expect '{substring}' in event message '{msg}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the event message should be "{expected}"')
|
||||
def step_event_message_should_be(context: Context, expected: str) -> None:
|
||||
msg = context.processed_event["event"]
|
||||
assert msg == expected, f"Expected event message '{expected}', got '{msg}'"
|
||||
|
||||
|
||||
# ── CLI error detail redaction ────────────────────────────────────
|
||||
|
||||
|
||||
@given('error details with key "{key}" and value "{value}"')
|
||||
def step_given_error_details(context: Context, key: str, value: str) -> None:
|
||||
context.error_details = {key: value}
|
||||
|
||||
|
||||
@when("I redact the error details for CLI display")
|
||||
def step_redact_error_details(context: Context) -> None:
|
||||
context.redacted_details = redact_dict(context.error_details)
|
||||
|
||||
|
||||
@then('the redacted details key "{key}" should be "{expected}"')
|
||||
def step_redacted_details_key(
|
||||
context: Context,
|
||||
key: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
actual = context.redacted_details[key]
|
||||
assert actual == expected, (
|
||||
f"Error details key '{key}': expected '{expected}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Secret pattern registration ──────────────────────────────────
|
||||
|
||||
|
||||
@given("the default secret patterns")
|
||||
def step_given_default_patterns(context: Context) -> None:
|
||||
# Nothing to do — patterns are already loaded at module import.
|
||||
# We store a marker so we know the Given ran.
|
||||
context.patterns_initialized = True
|
||||
|
||||
|
||||
@when('I register a custom pattern "{pattern}"')
|
||||
def step_register_custom_pattern(context: Context, pattern: str) -> None:
|
||||
register_pattern(pattern)
|
||||
|
||||
|
||||
@when('I redact the string "{value}"')
|
||||
def step_when_redact_string_inline(context: Context, value: str) -> None:
|
||||
context.input_string = value
|
||||
context.redacted_result = redact_value(value)
|
||||
+45
-45
@@ -5176,29 +5176,29 @@ By Day 30, the system must be able to (all in LOCAL MODE, server connectivity is
|
||||
- [ ] Git [Luis]: `git branch -d feature/m4-security-async-cleanup`
|
||||
|
||||
**Parallel Group SEC5: Secrets Management [Hamza]**
|
||||
- [ ] **COMMIT (Owner: Hamza | Group: SEC5.secrets | Branch: feature/m4-security-secrets | Planned: Day 13 | Expected: Day 15) - Commit message: "feat(security): add secrets masking and validation"**
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git pull origin master`
|
||||
- [ ] Git [Hamza]: `git checkout -b feature/m4-security-secrets`
|
||||
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Hamza]: Mask credentials in logs, validate required keys, and block secret leakage in outputs.
|
||||
- [ ] Code [Hamza]: Add a centralized redaction utility (token patterns + config keys) and integrate into CLI output formatting.
|
||||
- [ ] Code [Hamza]: Add `--show-secrets` guard flag (default off) for diagnostics that would otherwise print masked fields.
|
||||
- [ ] Code [Hamza]: Add secret detection for common key patterns (API keys, tokens) in config and env.
|
||||
- [ ] Code [Hamza]: Add redaction for error.details and validation outputs before printing/logging.
|
||||
- [ ] Docs [Hamza]: Add `docs/reference/secrets_handling.md`.
|
||||
- [ ] Docs [Hamza]: Include examples of masked outputs and `--show-secrets` usage.
|
||||
- [ ] Tests (Behave) [Hamza]: Add `features/security_secrets.feature` scenarios.
|
||||
- [ ] Tests (Robot) [Hamza]: Add secrets handling integration smoke tests.
|
||||
- [ ] Tests (ASV) [Hamza]: Add `benchmarks/security_secrets_bench.py` for masking overhead baseline.
|
||||
- [ ] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
|
||||
- [ ] Git [Hamza]: `git add .` (only after nox passes)
|
||||
- [ ] Git [Hamza]: `git commit -m "feat(security): add secrets masking and validation"`
|
||||
- [ ] Git [Hamza]: `git push -u origin feature/m4-security-secrets`
|
||||
- [ ] Forgejo PR [Hamza]: Open PR from `feature/m4-security-secrets` to `master` with description "Add secrets masking/validation and tests.".
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git branch -d feature/m4-security-secrets`
|
||||
- [x] **COMMIT (Owner: Hamza | Group: SEC5.secrets | Branch: feature/m4-security-secrets | Planned: Day 13 | Expected: Day 15) - Commit message: "feat(security): add secrets masking and validation"**
|
||||
- [x] Git [Hamza]: `git checkout master`
|
||||
- [x] Git [Hamza]: `git pull origin master`
|
||||
- [x] Git [Hamza]: `git checkout -b feature/m4-security-secrets`
|
||||
- [x] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [x] Code [Hamza]: Mask credentials in logs, validate required keys, and block secret leakage in outputs.
|
||||
- [x] Code [Hamza]: Add a centralized redaction utility (token patterns + config keys) and integrate into CLI output formatting.
|
||||
- [x] Code [Hamza]: Add `--show-secrets` guard flag (default off) for diagnostics that would otherwise print masked fields.
|
||||
- [x] Code [Hamza]: Add secret detection for common key patterns (API keys, tokens) in config and env.
|
||||
- [x] Code [Hamza]: Add redaction for error.details and validation outputs before printing/logging.
|
||||
- [x] Docs [Hamza]: Add `docs/reference/secrets_handling.md`.
|
||||
- [x] Docs [Hamza]: Include examples of masked outputs and `--show-secrets` usage.
|
||||
- [x] Tests (Behave) [Hamza]: Add `features/security_secrets.feature` scenarios.
|
||||
- [x] Tests (Robot) [Hamza]: Add secrets handling integration smoke tests.
|
||||
- [x] Tests (ASV) [Hamza]: Add `benchmarks/security_secrets_bench.py` for masking overhead baseline.
|
||||
- [x] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [x] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
|
||||
- [x] Git [Hamza]: `git add .` (only after nox passes)
|
||||
- [x] Git [Hamza]: `git commit -m "feat(security): add secrets masking and validation"`
|
||||
- [x] Git [Hamza]: `git push -u origin feature/m4-security-secrets`
|
||||
- [x] Forgejo PR [Hamza]: Open PR from `feature/m4-security-secrets` to `hamza-dev` with description "Add secrets masking/validation and tests.". (PR #87)
|
||||
- [x] Git [Hamza]: Branch kept for PR review (not deleted yet)
|
||||
- [x] Git [Hamza]: Branch kept for PR review (not deleted yet)
|
||||
|
||||
**Parallel Group SEC6: Read-Only Enforcement [Luis]**
|
||||
- [ ] **COMMIT (Owner: Luis | Group: SEC6.readonly | Branch: feature/m4-security-readonly | Planned: Day 13 | Expected: Day 15) - Commit message: "feat(security): enforce read-only actions"**
|
||||
@@ -5440,28 +5440,28 @@ By Day 30, the system must be able to (all in LOCAL MODE, server connectivity is
|
||||
- [ ] Git [Luis]: `git branch -d feature/m4-concurrency-resume`
|
||||
|
||||
**Parallel Group CONC3: Garbage Collection [Hamza]**
|
||||
- [ ] **COMMIT (Owner: Hamza | Group: CONC3.gc | Branch: feature/m4-concurrency-cleanup | Planned: Day 13 | Expected: Day 15) - Commit message: "feat(ops): add cleanup commands"**
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git pull origin master`
|
||||
- [ ] Git [Hamza]: `git checkout -b feature/m4-concurrency-cleanup`
|
||||
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Hamza]: Add cleanup for sandboxes, checkpoints, and stale sessions with CLI commands.
|
||||
- [ ] Code [Hamza]: Add retention policy settings for sandbox age, checkpoint count, and session inactivity.
|
||||
- [ ] Code [Hamza]: Add `cleanup --dry-run` output (counts + paths) and `--all` override for full purge.
|
||||
- [ ] Code [Hamza]: Ensure cleanup skips active sandboxes and running plans (log skipped items).
|
||||
- [ ] Code [Hamza]: Add per-resource cleanup summaries (sandboxes/checkpoints/sessions) to output.
|
||||
- [ ] Code [Hamza]: Add config keys for cleanup scheduling (manual-only in MVP; stub schedule flag).
|
||||
- [ ] Docs [Hamza]: Document cleanup commands and retention defaults.
|
||||
- [ ] Docs [Hamza]: Include example `cleanup --dry-run` output and warnings.
|
||||
- [ ] Tests (Behave) [Hamza]: Add `features/garbage_collection.feature` scenarios.
|
||||
- [ ] Tests (Robot) [Hamza]: Add cleanup integration smoke tests.
|
||||
- [ ] Tests (ASV) [Hamza]: Add `benchmarks/cleanup_bench.py` for cleanup overhead baseline.
|
||||
- [ ] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
|
||||
- [ ] Git [Hamza]: `git add .` (only after nox passes)
|
||||
- [ ] Git [Hamza]: `git commit -m "feat(ops): add cleanup commands"`
|
||||
- [ ] Git [Hamza]: `git push -u origin feature/m4-concurrency-cleanup`
|
||||
- [ ] Forgejo PR [Hamza]: Open PR from `feature/m4-concurrency-cleanup` to `master` with description "Add cleanup commands and retention policies with tests.".
|
||||
- [x] **COMMIT (Owner: Hamza | Group: CONC3.gc | Branch: feature/m4-concurrency-cleanup | Planned: Day 13 | Expected: Day 15) - Commit message: "feat(ops): add cleanup commands"**
|
||||
- [x] Git [Hamza]: `git checkout master`
|
||||
- [x] Git [Hamza]: `git pull origin master`
|
||||
- [x] Git [Hamza]: `git checkout -b feature/m4-concurrency-cleanup`
|
||||
- [x] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [x] Code [Hamza]: Add cleanup for sandboxes, checkpoints, and stale sessions with CLI commands.
|
||||
- [x] Code [Hamza]: Add retention policy settings for sandbox age, checkpoint count, and session inactivity.
|
||||
- [x] Code [Hamza]: Add `cleanup --dry-run` output (counts + paths) and `--all` override for full purge.
|
||||
- [x] Code [Hamza]: Ensure cleanup skips active sandboxes and running plans (log skipped items).
|
||||
- [x] Code [Hamza]: Add per-resource cleanup summaries (sandboxes/checkpoints/sessions) to output.
|
||||
- [x] Code [Hamza]: Add config keys for cleanup scheduling (manual-only in MVP; stub schedule flag).
|
||||
- [x] Docs [Hamza]: Document cleanup commands and retention defaults.
|
||||
- [x] Docs [Hamza]: Include example `cleanup --dry-run` output and warnings.
|
||||
- [x] Tests (Behave) [Hamza]: Add `features/garbage_collection.feature` scenarios.
|
||||
- [x] Tests (Robot) [Hamza]: Add cleanup integration smoke tests.
|
||||
- [x] Tests (ASV) [Hamza]: Add `benchmarks/cleanup_bench.py` for cleanup overhead baseline.
|
||||
- [x] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [x] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
|
||||
- [x] Git [Hamza]: `git add .` (only after nox passes)
|
||||
- [x] Git [Hamza]: `git commit -m "feat(ops): add cleanup commands"`
|
||||
- [x] Git [Hamza]: `git push -u origin feature/m4-concurrency-cleanup`
|
||||
- [x] Forgejo PR [Hamza]: Open PR from `feature/m4-concurrency-cleanup` to `master` with description "Add cleanup commands and retention policies with tests.".
|
||||
- [ ] Git [Hamza]: `git checkout master`
|
||||
- [ ] Git [Hamza]: `git branch -d feature/m4-concurrency-cleanup`
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
*** Settings ***
|
||||
Documentation CONC3 - Cleanup commands integration smoke tests
|
||||
Library OperatingSystem
|
||||
Library Collections
|
||||
Library String
|
||||
Resource ${CURDIR}/common.resource
|
||||
|
||||
*** Variables ***
|
||||
${SRC_DIR} ${CURDIR}/../src/cleveragents
|
||||
|
||||
*** Test Cases ***
|
||||
Cleanup Service Module Exists
|
||||
[Documentation] Verify cleanup service module is present
|
||||
File Should Exist ${SRC_DIR}/application/services/cleanup_service.py
|
||||
|
||||
Cleanup CLI Module Exists
|
||||
[Documentation] Verify cleanup CLI command module is present
|
||||
File Should Exist ${SRC_DIR}/cli/commands/cleanup.py
|
||||
|
||||
Cleanup Service Exports Expected Symbols
|
||||
[Documentation] Verify cleanup_service.py exports the public API
|
||||
${content}= Get File ${SRC_DIR}/application/services/cleanup_service.py
|
||||
Should Contain ${content} class CleanupService
|
||||
Should Contain ${content} class CleanupReport
|
||||
Should Contain ${content} class StaleItem
|
||||
Should Contain ${content} class ResourceCleanupSummary
|
||||
Should Contain ${content} def scan
|
||||
Should Contain ${content} def purge
|
||||
|
||||
Cleanup CLI Has Scan Command
|
||||
[Documentation] Verify cleanup CLI has scan subcommand
|
||||
${content}= Get File ${SRC_DIR}/cli/commands/cleanup.py
|
||||
Should Contain ${content} def scan
|
||||
Should Contain ${content} dry-run
|
||||
|
||||
Cleanup CLI Has Purge Command
|
||||
[Documentation] Verify cleanup CLI has purge subcommand
|
||||
${content}= Get File ${SRC_DIR}/cli/commands/cleanup.py
|
||||
Should Contain ${content} def purge
|
||||
Should Contain ${content} --all
|
||||
Should Contain ${content} --dry-run
|
||||
Should Contain ${content} --yes
|
||||
|
||||
Cleanup CLI Has Status Command
|
||||
[Documentation] Verify cleanup CLI has status subcommand
|
||||
${content}= Get File ${SRC_DIR}/cli/commands/cleanup.py
|
||||
Should Contain ${content} def status
|
||||
Should Contain ${content} Retention Policies
|
||||
|
||||
Settings Has Retention Policy Fields
|
||||
[Documentation] Verify settings includes cleanup config fields
|
||||
${content}= Get File ${SRC_DIR}/config/settings.py
|
||||
Should Contain ${content} cleanup_sandbox_max_age_hours
|
||||
Should Contain ${content} cleanup_checkpoint_max_per_plan
|
||||
Should Contain ${content} cleanup_session_inactivity_days
|
||||
Should Contain ${content} cleanup_log_retention_days
|
||||
Should Contain ${content} cleanup_backup_retention_days
|
||||
Should Contain ${content} cleanup_schedule
|
||||
|
||||
Main CLI Registers Cleanup Command
|
||||
[Documentation] Verify main.py registers the cleanup command group
|
||||
${content}= Get File ${SRC_DIR}/cli/main.py
|
||||
Should Contain ${content} cleanup
|
||||
Should Contain ${content} Garbage collection
|
||||
|
||||
Cleanup Service Does Not Use Eval Or Exec
|
||||
[Documentation] Security check: no eval/exec in cleanup service
|
||||
${content}= Get File ${SRC_DIR}/application/services/cleanup_service.py
|
||||
Should Not Contain ${content} eval(
|
||||
Should Not Contain ${content} exec(
|
||||
@@ -0,0 +1,74 @@
|
||||
*** Settings ***
|
||||
Documentation SEC5 - Secrets masking integration smoke tests
|
||||
Library OperatingSystem
|
||||
Library Collections
|
||||
Library String
|
||||
Library Process
|
||||
Resource ${CURDIR}/common.resource
|
||||
|
||||
*** Variables ***
|
||||
${SRC_DIR} ${CURDIR}/../src/cleveragents
|
||||
|
||||
*** Test Cases ***
|
||||
Redaction Module Exists
|
||||
[Documentation] Verify the redaction module is present
|
||||
File Should Exist ${SRC_DIR}/shared/redaction.py
|
||||
|
||||
Logging Config Module Exists
|
||||
[Documentation] Verify the structlog config module is present
|
||||
File Should Exist ${SRC_DIR}/config/logging.py
|
||||
|
||||
Redaction Module Exports Expected Symbols
|
||||
[Documentation] Verify redaction.py exports the public API
|
||||
${content}= Get File ${SRC_DIR}/shared/redaction.py
|
||||
Should Contain ${content} def redact_value
|
||||
Should Contain ${content} def redact_dict
|
||||
Should Contain ${content} def is_sensitive_key
|
||||
Should Contain ${content} def mask_database_url
|
||||
Should Contain ${content} def register_pattern
|
||||
Should Contain ${content} def secrets_masking_processor
|
||||
Should Contain ${content} def get_show_secrets
|
||||
Should Contain ${content} def set_show_secrets
|
||||
Should Contain ${content} REDACTED
|
||||
|
||||
Main CLI Declares Show Secrets Option
|
||||
[Documentation] Verify --show-secrets global option is wired in
|
||||
${content}= Get File ${SRC_DIR}/cli/main.py
|
||||
Should Contain ${content} --show-secrets
|
||||
Should Contain ${content} show_secrets_callback
|
||||
|
||||
CLI Error Handlers Use Redaction
|
||||
[Documentation] Verify error handlers in main.py redact details
|
||||
${content}= Get File ${SRC_DIR}/cli/main.py
|
||||
Should Contain ${content} redact_value(e.message)
|
||||
Should Contain ${content} redact_dict(e.details)
|
||||
|
||||
Formatting Module Applies Redaction
|
||||
[Documentation] Verify format_output applies dict redaction
|
||||
${content}= Get File ${SRC_DIR}/cli/formatting.py
|
||||
Should Contain ${content} _redact_data
|
||||
Should Contain ${content} safe_data
|
||||
|
||||
Settings Has Show Secrets Field
|
||||
[Documentation] Verify Settings model includes show_secrets field
|
||||
${content}= Get File ${SRC_DIR}/config/settings.py
|
||||
Should Contain ${content} show_secrets: bool
|
||||
Should Contain ${content} CLEVERAGENTS_SHOW_SECRETS
|
||||
|
||||
Settings Has Safe Repr
|
||||
[Documentation] Verify Settings.__repr__ masks sensitive values
|
||||
${content}= Get File ${SRC_DIR}/config/settings.py
|
||||
Should Contain ${content} def __repr__
|
||||
Should Contain ${content} is_sensitive_key
|
||||
|
||||
Redaction Module Does Not Use Eval Or Exec
|
||||
[Documentation] Security check: no eval/exec in redaction module
|
||||
${content}= Get File ${SRC_DIR}/shared/redaction.py
|
||||
Should Not Contain ${content} eval(
|
||||
Should Not Contain ${content} exec(
|
||||
|
||||
Logging Config Integrates Masking Processor
|
||||
[Documentation] Verify structlog config uses secrets_masking_processor
|
||||
${content}= Get File ${SRC_DIR}/config/logging.py
|
||||
Should Contain ${content} secrets_masking_processor
|
||||
Should Contain ${content} configure_structlog
|
||||
@@ -0,0 +1,521 @@
|
||||
"""Garbage collection and cleanup service (CONC3).
|
||||
|
||||
Provides centralized cleanup for stale sandboxes, excess checkpoints,
|
||||
inactive sessions, expired logs, and expired backups. Supports
|
||||
dry-run mode (report without deleting) and full purge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
__all__ = [
|
||||
"CleanupReport",
|
||||
"CleanupService",
|
||||
"ResourceCleanupSummary",
|
||||
"StaleItem",
|
||||
]
|
||||
|
||||
|
||||
# ── Data classes ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StaleItem:
|
||||
"""A single item identified for cleanup."""
|
||||
|
||||
resource_type: str
|
||||
path: str
|
||||
age_description: str
|
||||
plan_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ResourceCleanupSummary:
|
||||
"""Per-resource-type cleanup result."""
|
||||
|
||||
resource_type: str
|
||||
scanned: int = 0
|
||||
removed: int = 0
|
||||
skipped: int = 0
|
||||
skipped_reasons: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CleanupReport:
|
||||
"""Full cleanup report across all resource types."""
|
||||
|
||||
dry_run: bool = False
|
||||
sandboxes: ResourceCleanupSummary = field(
|
||||
default_factory=lambda: ResourceCleanupSummary(resource_type="sandboxes"),
|
||||
)
|
||||
checkpoints: ResourceCleanupSummary = field(
|
||||
default_factory=lambda: ResourceCleanupSummary(resource_type="checkpoints"),
|
||||
)
|
||||
sessions: ResourceCleanupSummary = field(
|
||||
default_factory=lambda: ResourceCleanupSummary(resource_type="sessions"),
|
||||
)
|
||||
logs: ResourceCleanupSummary = field(
|
||||
default_factory=lambda: ResourceCleanupSummary(resource_type="logs"),
|
||||
)
|
||||
backups: ResourceCleanupSummary = field(
|
||||
default_factory=lambda: ResourceCleanupSummary(resource_type="backups"),
|
||||
)
|
||||
stale_items: list[StaleItem] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the report for CLI output."""
|
||||
return {
|
||||
"dry_run": self.dry_run,
|
||||
"sandboxes": {
|
||||
"scanned": self.sandboxes.scanned,
|
||||
"removed": self.sandboxes.removed,
|
||||
"skipped": self.sandboxes.skipped,
|
||||
},
|
||||
"checkpoints": {
|
||||
"scanned": self.checkpoints.scanned,
|
||||
"removed": self.checkpoints.removed,
|
||||
"skipped": self.checkpoints.skipped,
|
||||
},
|
||||
"sessions": {
|
||||
"scanned": self.sessions.scanned,
|
||||
"removed": self.sessions.removed,
|
||||
"skipped": self.sessions.skipped,
|
||||
},
|
||||
"logs": {
|
||||
"scanned": self.logs.scanned,
|
||||
"removed": self.logs.removed,
|
||||
"skipped": self.logs.skipped,
|
||||
},
|
||||
"backups": {
|
||||
"scanned": self.backups.scanned,
|
||||
"removed": self.backups.removed,
|
||||
"skipped": self.backups.skipped,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CleanupService:
|
||||
"""Orchestrates garbage collection for the platform.
|
||||
|
||||
Args:
|
||||
settings: Application settings with retention policy fields.
|
||||
active_plan_ids: Set of plan IDs currently running (protected).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
active_plan_ids: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
if not isinstance(settings, Settings):
|
||||
raise TypeError("settings must be a Settings instance")
|
||||
self._settings = settings
|
||||
self._active_plan_ids: frozenset[str] = active_plan_ids or frozenset()
|
||||
self._sandbox_dirs_cache: list[Path] | None = None
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────
|
||||
|
||||
def scan(self) -> CleanupReport:
|
||||
"""Scan for stale resources without deleting anything.
|
||||
|
||||
Returns:
|
||||
A CleanupReport with dry_run=True containing identified
|
||||
stale items and per-resource counts.
|
||||
"""
|
||||
report = CleanupReport(dry_run=True)
|
||||
self._scan_sandboxes(report)
|
||||
self._scan_checkpoints(report)
|
||||
self._scan_sessions(report)
|
||||
self._scan_logs(report)
|
||||
self._scan_backups(report)
|
||||
return report
|
||||
|
||||
def purge(self, *, purge_all: bool = False) -> CleanupReport:
|
||||
"""Delete stale resources.
|
||||
|
||||
Args:
|
||||
purge_all: When True, purge all resource types.
|
||||
When False, only purge sandboxes and checkpoints.
|
||||
|
||||
Returns:
|
||||
A CleanupReport with removal counts.
|
||||
"""
|
||||
report = CleanupReport(dry_run=False)
|
||||
self._purge_sandboxes(report)
|
||||
self._purge_checkpoints(report)
|
||||
if purge_all:
|
||||
self._purge_sessions(report)
|
||||
self._purge_logs(report)
|
||||
self._purge_backups(report)
|
||||
return report
|
||||
|
||||
# ── Sandbox cleanup ───────────────────────────────────────────
|
||||
|
||||
def _get_sandbox_dirs(self) -> list[Path]:
|
||||
"""Find sandbox directories in the system temp directory.
|
||||
|
||||
Results are cached for the lifetime of the service instance so
|
||||
that ``scan()`` and ``purge()`` (and the CLI active-plan check)
|
||||
do not redundantly iterate ``/tmp``.
|
||||
"""
|
||||
if self._sandbox_dirs_cache is not None:
|
||||
return self._sandbox_dirs_cache
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
if not tmp.exists():
|
||||
self._sandbox_dirs_cache = []
|
||||
return self._sandbox_dirs_cache
|
||||
dirs: list[Path] = []
|
||||
try:
|
||||
entries = list(tmp.iterdir())
|
||||
except OSError:
|
||||
self._sandbox_dirs_cache = []
|
||||
return self._sandbox_dirs_cache
|
||||
for p in entries:
|
||||
try:
|
||||
is_dir = p.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
if is_dir and any(
|
||||
p.name.startswith(pfx) for pfx in ("ca-sandbox-", "ca-cow-sandbox-")
|
||||
):
|
||||
dirs.append(p)
|
||||
self._sandbox_dirs_cache = dirs
|
||||
return dirs
|
||||
|
||||
@staticmethod
|
||||
def extract_plan_id_from_sandbox(path: Path) -> str | None:
|
||||
"""Extract plan_id from a sandbox directory name.
|
||||
|
||||
Handles both standard (``ca-sandbox-<plan_id>-<random>``) and
|
||||
copy-on-write (``ca-cow-sandbox-<plan_id>-<random>``) formats.
|
||||
|
||||
This is a public static method so that callers (e.g. the CLI
|
||||
active-plan detection) can reuse the parsing logic without
|
||||
constructing a full :class:`CleanupService`.
|
||||
"""
|
||||
name = path.name
|
||||
for prefix in ("ca-cow-sandbox-", "ca-sandbox-"):
|
||||
if name.startswith(prefix):
|
||||
# Format: <prefix><plan_id>-<random>
|
||||
remainder = name[len(prefix) :]
|
||||
parts = remainder.rsplit("-", 1)
|
||||
if parts and parts[0]:
|
||||
return parts[0]
|
||||
return None
|
||||
|
||||
def _is_sandbox_stale(self, path: Path) -> bool:
|
||||
"""Check if a sandbox directory exceeds the max age."""
|
||||
max_age_seconds = self._settings.cleanup_sandbox_max_age_hours * 3600
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
return (time.time() - mtime) > max_age_seconds
|
||||
|
||||
def _scan_sandboxes(self, report: CleanupReport) -> None:
|
||||
"""Populate report with stale sandbox info."""
|
||||
dirs = self._get_sandbox_dirs()
|
||||
for d in dirs:
|
||||
report.sandboxes.scanned += 1
|
||||
plan_id = self.extract_plan_id_from_sandbox(d)
|
||||
if plan_id and plan_id in self._active_plan_ids:
|
||||
report.sandboxes.skipped += 1
|
||||
report.sandboxes.skipped_reasons.append(
|
||||
f"Skipped {d.name}: linked to running plan {plan_id}"
|
||||
)
|
||||
continue
|
||||
if self._is_sandbox_stale(d):
|
||||
report.stale_items.append(
|
||||
StaleItem(
|
||||
resource_type="sandbox",
|
||||
path=str(d),
|
||||
age_description=self._age_description(d),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
)
|
||||
|
||||
def _purge_sandboxes(self, report: CleanupReport) -> None:
|
||||
"""Remove stale sandbox directories."""
|
||||
dirs = self._get_sandbox_dirs()
|
||||
for d in dirs:
|
||||
report.sandboxes.scanned += 1
|
||||
plan_id = self.extract_plan_id_from_sandbox(d)
|
||||
if plan_id and plan_id in self._active_plan_ids:
|
||||
report.sandboxes.skipped += 1
|
||||
report.sandboxes.skipped_reasons.append(
|
||||
f"Skipped {d.name}: linked to running plan {plan_id}"
|
||||
)
|
||||
continue
|
||||
if self._is_sandbox_stale(d):
|
||||
try:
|
||||
shutil.rmtree(d)
|
||||
report.sandboxes.removed += 1
|
||||
except OSError:
|
||||
report.sandboxes.skipped += 1
|
||||
|
||||
# ── Checkpoint cleanup ────────────────────────────────────────
|
||||
|
||||
def scan_checkpoints_for_plan(
|
||||
self,
|
||||
checkpoint_dir: Path,
|
||||
) -> list[Path]:
|
||||
"""Return excess checkpoint files for a plan directory.
|
||||
|
||||
Keeps the first and most recent, prunes the rest when count
|
||||
exceeds ``cleanup_checkpoint_max_per_plan``.
|
||||
"""
|
||||
if not checkpoint_dir.exists():
|
||||
return []
|
||||
files = sorted(checkpoint_dir.glob("checkpoint_*.json"))
|
||||
max_count = self._settings.cleanup_checkpoint_max_per_plan
|
||||
if len(files) <= max_count:
|
||||
return []
|
||||
# Keep first and last, prune from the middle
|
||||
if len(files) < 3:
|
||||
return []
|
||||
middle = files[1:-1]
|
||||
excess_count = len(files) - max_count
|
||||
return middle[:excess_count]
|
||||
|
||||
def prune_checkpoints_for_plan(
|
||||
self,
|
||||
checkpoint_dir: Path,
|
||||
) -> int:
|
||||
"""Delete excess checkpoint files. Returns count removed."""
|
||||
to_remove = self.scan_checkpoints_for_plan(checkpoint_dir)
|
||||
removed = 0
|
||||
for f in to_remove:
|
||||
try:
|
||||
f.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
def _scan_checkpoints(self, report: CleanupReport) -> None:
|
||||
"""Scan for excess checkpoints across all plan directories."""
|
||||
checkpoint_base = self._get_checkpoint_base_dir()
|
||||
if not checkpoint_base.exists():
|
||||
return
|
||||
for plan_dir in checkpoint_base.iterdir():
|
||||
if not plan_dir.is_dir():
|
||||
continue
|
||||
excess = self.scan_checkpoints_for_plan(plan_dir)
|
||||
report.checkpoints.scanned += len(list(plan_dir.glob("checkpoint_*.json")))
|
||||
for f in excess:
|
||||
report.stale_items.append(
|
||||
StaleItem(
|
||||
resource_type="checkpoint",
|
||||
path=str(f),
|
||||
age_description="excess",
|
||||
)
|
||||
)
|
||||
|
||||
def _purge_checkpoints(self, report: CleanupReport) -> None:
|
||||
"""Prune excess checkpoints across all plan directories."""
|
||||
checkpoint_base = self._get_checkpoint_base_dir()
|
||||
if not checkpoint_base.exists():
|
||||
return
|
||||
for plan_dir in checkpoint_base.iterdir():
|
||||
if not plan_dir.is_dir():
|
||||
continue
|
||||
files = list(plan_dir.glob("checkpoint_*.json"))
|
||||
report.checkpoints.scanned += len(files)
|
||||
removed = self.prune_checkpoints_for_plan(plan_dir)
|
||||
report.checkpoints.removed += removed
|
||||
|
||||
def _get_checkpoint_base_dir(self) -> Path:
|
||||
"""Return the base directory where plan checkpoint dirs live."""
|
||||
data_dir = self._settings.data_dir
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = Path.cwd() / data_dir
|
||||
return data_dir / "checkpoints"
|
||||
|
||||
# ── Session cleanup ───────────────────────────────────────────
|
||||
|
||||
def scan_inactive_sessions(
|
||||
self,
|
||||
sessions: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter sessions that exceed the inactivity threshold.
|
||||
|
||||
Args:
|
||||
sessions: List of session dicts with ``updated_at`` key
|
||||
as a :class:`datetime` or ISO-8601 string.
|
||||
|
||||
Returns:
|
||||
Sessions that are inactive beyond the threshold.
|
||||
"""
|
||||
threshold_days = self._settings.cleanup_session_inactivity_days
|
||||
now = datetime.now(tz=UTC)
|
||||
inactive: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
updated = session.get("updated_at")
|
||||
if updated is None:
|
||||
continue
|
||||
if isinstance(updated, str):
|
||||
updated = datetime.fromisoformat(updated)
|
||||
if not updated.tzinfo:
|
||||
updated = updated.replace(tzinfo=UTC)
|
||||
age_days = (now - updated).days
|
||||
if age_days > threshold_days:
|
||||
inactive.append(session)
|
||||
return inactive
|
||||
|
||||
def _scan_sessions(self, report: CleanupReport) -> None:
|
||||
"""Placeholder scan — session cleanup requires DB access.
|
||||
|
||||
Session cleanup is not yet implemented in MVP. The
|
||||
``scan_inactive_sessions`` method is available for callers
|
||||
that can provide session dicts from the database.
|
||||
"""
|
||||
# TODO(CONC3): Wire DB session query when Container is
|
||||
# available at CLI startup.
|
||||
|
||||
def _purge_sessions(self, report: CleanupReport) -> None:
|
||||
"""Placeholder purge — session cleanup requires DB access.
|
||||
|
||||
Session deletion is not yet implemented in MVP.
|
||||
"""
|
||||
# TODO(CONC3): Wire DB session deletion when Container is
|
||||
# available at CLI startup.
|
||||
|
||||
# ── Log cleanup ───────────────────────────────────────────────
|
||||
|
||||
def scan_expired_files(
|
||||
self,
|
||||
directory: Path,
|
||||
retention_days: int,
|
||||
pattern: str = "*.log",
|
||||
) -> list[Path]:
|
||||
"""Return files in *directory* older than *retention_days*."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
max_age_seconds = retention_days * 86400
|
||||
now = time.time()
|
||||
expired: list[Path] = []
|
||||
for f in directory.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
mtime = f.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if (now - mtime) > max_age_seconds:
|
||||
expired.append(f)
|
||||
return expired
|
||||
|
||||
def _scan_logs(self, report: CleanupReport) -> None:
|
||||
"""Scan for expired log files."""
|
||||
log_dir = self._settings.log_dir
|
||||
if not log_dir.is_absolute():
|
||||
log_dir = Path.cwd() / log_dir
|
||||
expired = self.scan_expired_files(
|
||||
log_dir,
|
||||
self._settings.cleanup_log_retention_days,
|
||||
pattern="*.log",
|
||||
)
|
||||
report.logs.scanned += len(
|
||||
list(log_dir.glob("*.log")) if log_dir.exists() else []
|
||||
)
|
||||
for f in expired:
|
||||
report.stale_items.append(
|
||||
StaleItem(
|
||||
resource_type="log",
|
||||
path=str(f),
|
||||
age_description=self._age_description(f),
|
||||
)
|
||||
)
|
||||
|
||||
def _purge_logs(self, report: CleanupReport) -> None:
|
||||
"""Delete expired log files."""
|
||||
log_dir = self._settings.log_dir
|
||||
if not log_dir.is_absolute():
|
||||
log_dir = Path.cwd() / log_dir
|
||||
expired = self.scan_expired_files(
|
||||
log_dir,
|
||||
self._settings.cleanup_log_retention_days,
|
||||
pattern="*.log",
|
||||
)
|
||||
report.logs.scanned += len(
|
||||
list(log_dir.glob("*.log")) if log_dir.exists() else []
|
||||
)
|
||||
for f in expired:
|
||||
try:
|
||||
f.unlink()
|
||||
report.logs.removed += 1
|
||||
except OSError:
|
||||
report.logs.skipped += 1
|
||||
|
||||
# ── Backup cleanup ────────────────────────────────────────────
|
||||
|
||||
def _get_backup_dir(self) -> Path:
|
||||
"""Return the backup directory."""
|
||||
data_dir = self._settings.data_dir
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = Path.cwd() / data_dir
|
||||
return data_dir / "backups"
|
||||
|
||||
def _scan_backups(self, report: CleanupReport) -> None:
|
||||
"""Scan for expired backup files."""
|
||||
backup_dir = self._get_backup_dir()
|
||||
expired = self.scan_expired_files(
|
||||
backup_dir,
|
||||
self._settings.cleanup_backup_retention_days,
|
||||
pattern="*",
|
||||
)
|
||||
all_files = list(backup_dir.iterdir()) if backup_dir.exists() else []
|
||||
report.backups.scanned += len([f for f in all_files if f.is_file()])
|
||||
for f in expired:
|
||||
report.stale_items.append(
|
||||
StaleItem(
|
||||
resource_type="backup",
|
||||
path=str(f),
|
||||
age_description=self._age_description(f),
|
||||
)
|
||||
)
|
||||
|
||||
def _purge_backups(self, report: CleanupReport) -> None:
|
||||
"""Delete expired backup files."""
|
||||
backup_dir = self._get_backup_dir()
|
||||
expired = self.scan_expired_files(
|
||||
backup_dir,
|
||||
self._settings.cleanup_backup_retention_days,
|
||||
pattern="*",
|
||||
)
|
||||
all_files = list(backup_dir.iterdir()) if backup_dir.exists() else []
|
||||
report.backups.scanned += len([f for f in all_files if f.is_file()])
|
||||
for f in expired:
|
||||
try:
|
||||
f.unlink()
|
||||
report.backups.removed += 1
|
||||
except OSError:
|
||||
report.backups.skipped += 1
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _age_description(path: Path) -> str:
|
||||
"""Human-readable age description for a file or directory."""
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return "unknown age"
|
||||
age_seconds = time.time() - mtime
|
||||
if age_seconds < 3600:
|
||||
return f"{int(age_seconds / 60)} minutes old"
|
||||
if age_seconds < 86400:
|
||||
return f"{int(age_seconds / 3600)} hours old"
|
||||
return f"{int(age_seconds / 86400)} days old"
|
||||
@@ -244,9 +244,12 @@ def run(
|
||||
raise typer.Exit(1)
|
||||
|
||||
except PlanError as e:
|
||||
console.print(f"[red]Plan Error:[/red] {e.message}")
|
||||
from cleveragents.shared.redaction import redact_dict, redact_value
|
||||
|
||||
console.print(f"[red]Plan Error:[/red] {redact_value(e.message)}")
|
||||
if e.details:
|
||||
for key, value in e.details.items():
|
||||
safe = redact_dict(e.details)
|
||||
for key, value in safe.items():
|
||||
console.print(f" [dim]{key}:[/dim] {value}")
|
||||
raise typer.Abort() from e
|
||||
except CleverAgentsError as e:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""CLI commands for garbage collection and cleanup (CONC3).
|
||||
|
||||
Provides ``agents cleanup`` with subcommands for scanning and purging
|
||||
stale sandboxes, checkpoints, sessions, logs, and backups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from cleveragents.cli.main import get_console, get_err_console
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.application.services.cleanup_service import (
|
||||
CleanupReport,
|
||||
CleanupService,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
help="Garbage collection and cleanup for stale resources.",
|
||||
)
|
||||
|
||||
|
||||
def _detect_active_plan_ids(service: CleanupService) -> frozenset[str]:
|
||||
"""Best-effort detection of currently active plan IDs.
|
||||
|
||||
Reuses the service's cached sandbox directory listing and its
|
||||
:meth:`~CleanupService.extract_plan_id_from_sandbox` parser so
|
||||
that ``/tmp`` is only iterated once per CLI invocation.
|
||||
|
||||
Sandboxes modified within the last hour are assumed to belong
|
||||
to running plans. Falls back to an empty set with a warning
|
||||
when the temp directory cannot be read.
|
||||
"""
|
||||
import time
|
||||
|
||||
try:
|
||||
active: set[str] = set()
|
||||
one_hour_ago = time.time() - 3600
|
||||
for p in service._get_sandbox_dirs():
|
||||
plan_id = service.extract_plan_id_from_sandbox(p)
|
||||
if plan_id:
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
if mtime > one_hour_ago:
|
||||
active.add(plan_id)
|
||||
except OSError:
|
||||
pass
|
||||
return frozenset(active)
|
||||
except OSError:
|
||||
err = get_err_console()
|
||||
err.print(
|
||||
"[yellow]Warning: Could not scan for active plans. "
|
||||
"Running plans will NOT be protected from cleanup.[/yellow]"
|
||||
)
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _get_cleanup_service() -> CleanupService:
|
||||
"""Build a :class:`CleanupService` from current settings.
|
||||
|
||||
Constructs the service, detects active plans from the cached
|
||||
sandbox listing, then injects the active set before returning.
|
||||
This ensures ``/tmp`` is iterated only once.
|
||||
"""
|
||||
from cleveragents.application.services.cleanup_service import CleanupService
|
||||
from cleveragents.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
service = CleanupService(settings=settings)
|
||||
service._active_plan_ids = _detect_active_plan_ids(service)
|
||||
return service
|
||||
|
||||
|
||||
@app.command(name="scan")
|
||||
def scan() -> None:
|
||||
"""Scan for stale resources without deleting anything (dry-run)."""
|
||||
console = get_console()
|
||||
service = _get_cleanup_service()
|
||||
report = service.scan()
|
||||
|
||||
console.print("\n[bold]Cleanup Scan Results (dry-run)[/bold]\n")
|
||||
_print_summary(report)
|
||||
|
||||
if report.stale_items:
|
||||
console.print("\n[dim]Stale items found:[/dim]")
|
||||
for item in report.stale_items:
|
||||
console.print(
|
||||
f" [{item.resource_type}] {item.path} ({item.age_description})"
|
||||
)
|
||||
else:
|
||||
console.print("\n[green]No stale resources found.[/green]")
|
||||
|
||||
|
||||
@app.command(name="purge")
|
||||
def purge(
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", help="Show what would be cleaned without deleting."),
|
||||
] = False,
|
||||
purge_all: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--all",
|
||||
help=(
|
||||
"Purge all resource types "
|
||||
"(sandboxes, checkpoints, sessions, "
|
||||
"logs, backups)."
|
||||
),
|
||||
),
|
||||
] = False,
|
||||
yes: Annotated[
|
||||
bool,
|
||||
typer.Option("--yes", "-y", help="Skip confirmation prompt."),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Remove stale resources based on retention policies."""
|
||||
console = get_console()
|
||||
err_console = get_err_console()
|
||||
service = _get_cleanup_service()
|
||||
|
||||
if dry_run:
|
||||
report = service.scan()
|
||||
console.print("\n[bold]Cleanup Dry-Run Report[/bold]\n")
|
||||
_print_summary(report)
|
||||
if report.stale_items:
|
||||
console.print("\n[dim]Would clean:[/dim]")
|
||||
for item in report.stale_items:
|
||||
console.print(f" [{item.resource_type}] {item.path}")
|
||||
else:
|
||||
console.print("\n[green]Nothing to clean.[/green]")
|
||||
return
|
||||
|
||||
if not yes:
|
||||
scope = "all resource types" if purge_all else "sandboxes and checkpoints"
|
||||
confirm = typer.confirm(f"Purge stale {scope}?")
|
||||
if not confirm:
|
||||
err_console.print("[yellow]Aborted.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
report = service.purge(purge_all=purge_all)
|
||||
|
||||
console.print("\n[bold]Cleanup Complete[/bold]\n")
|
||||
_print_summary(report)
|
||||
|
||||
if purge_all and report.sessions.removed == 0:
|
||||
console.print(
|
||||
"\n[dim]Note: Session cleanup is not yet implemented. "
|
||||
"Inactive sessions must be removed manually.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="status")
|
||||
def status() -> None:
|
||||
"""Show current retention policy settings."""
|
||||
from cleveragents.config.settings import get_settings
|
||||
|
||||
console = get_console()
|
||||
settings = get_settings()
|
||||
|
||||
console.print("\n[bold]Cleanup Retention Policies[/bold]\n")
|
||||
console.print(
|
||||
f" Sandbox max age: {settings.cleanup_sandbox_max_age_hours} hours"
|
||||
)
|
||||
console.print(
|
||||
f" Checkpoint max/plan: {settings.cleanup_checkpoint_max_per_plan}"
|
||||
)
|
||||
console.print(
|
||||
f" Session inactivity: {settings.cleanup_session_inactivity_days} days"
|
||||
)
|
||||
console.print(
|
||||
f" Log retention: {settings.cleanup_log_retention_days} days"
|
||||
)
|
||||
console.print(
|
||||
f" Backup retention: {settings.cleanup_backup_retention_days} days"
|
||||
)
|
||||
console.print(f" Schedule: {settings.cleanup_schedule}")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_summary(report: CleanupReport) -> None:
|
||||
"""Print the per-resource cleanup summary table."""
|
||||
|
||||
console = get_console()
|
||||
data = report.as_dict()
|
||||
mode = "Dry-run" if report.dry_run else "Purge"
|
||||
|
||||
console.print(f" [dim]Mode:[/dim] {mode}")
|
||||
for resource_type in ("sandboxes", "checkpoints", "sessions", "logs", "backups"):
|
||||
info = data[resource_type]
|
||||
removed_label = "to remove" if report.dry_run else "removed"
|
||||
console.print(
|
||||
f" {resource_type.capitalize():15s} "
|
||||
f"scanned={info['scanned']} "
|
||||
f"{removed_label}={info['removed']} "
|
||||
f"skipped={info['skipped']}"
|
||||
)
|
||||
@@ -401,9 +401,12 @@ def init(
|
||||
default_filters=default_filters,
|
||||
)
|
||||
except ValidationError as e:
|
||||
err_console.print(f"[red]Validation Error:[/red] {e.message}")
|
||||
from cleveragents.shared.redaction import redact_dict, redact_value
|
||||
|
||||
err_console.print(f"[red]Validation Error:[/red] {redact_value(e.message)}")
|
||||
if e.details:
|
||||
for key, value in e.details.items():
|
||||
safe = redact_dict(e.details)
|
||||
for key, value in safe.items():
|
||||
err_console.print(f" {key}: {value}")
|
||||
raise typer.Abort() from e
|
||||
except ConfigurationError as e:
|
||||
|
||||
@@ -133,12 +133,27 @@ def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str:
|
||||
return buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
def _redact_data(
|
||||
data: dict[str, Any] | list[dict[str, Any]],
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Apply secrets redaction to output data before rendering."""
|
||||
from cleveragents.shared.redaction import redact_dict
|
||||
|
||||
if isinstance(data, list):
|
||||
return [redact_dict(item) if isinstance(item, dict) else item for item in data]
|
||||
return redact_dict(data)
|
||||
|
||||
|
||||
def format_output(
|
||||
data: dict[str, Any] | list[dict[str, Any]],
|
||||
format_type: str,
|
||||
) -> str:
|
||||
"""Format *data* according to *format_type*.
|
||||
|
||||
All output is passed through :func:`redact_dict` before
|
||||
rendering so that sensitive values are masked unless the
|
||||
global ``show_secrets`` flag is enabled.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
@@ -153,14 +168,15 @@ def format_output(
|
||||
the domain-specific Rich helper instead; this function returns
|
||||
the JSON representation as a fallback.
|
||||
"""
|
||||
safe_data = _redact_data(data)
|
||||
fmt = format_type.lower()
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
return _format_json(data)
|
||||
return _format_json(safe_data)
|
||||
if fmt == OutputFormat.YAML.value:
|
||||
return _format_yaml(data)
|
||||
return _format_yaml(safe_data)
|
||||
if fmt == OutputFormat.PLAIN.value:
|
||||
return _format_plain(data)
|
||||
return _format_plain(safe_data)
|
||||
if fmt == OutputFormat.TABLE.value:
|
||||
return _format_table(data)
|
||||
return _format_table(safe_data)
|
||||
# ``rich`` and any unknown value fall back to JSON
|
||||
return _format_json(data)
|
||||
return _format_json(safe_data)
|
||||
|
||||
@@ -79,6 +79,7 @@ def _register_subcommands() -> None:
|
||||
from cleveragents.cli.commands import (
|
||||
action,
|
||||
actor,
|
||||
cleanup,
|
||||
context,
|
||||
plan,
|
||||
project,
|
||||
@@ -128,6 +129,11 @@ def _register_subcommands() -> None:
|
||||
name="skill",
|
||||
help="Manage skills (reusable, namespaced tool collections)",
|
||||
)
|
||||
app.add_typer(
|
||||
cleanup.app,
|
||||
name="cleanup",
|
||||
help="Garbage collection and cleanup for stale resources",
|
||||
)
|
||||
|
||||
_subcommands_registered = True
|
||||
|
||||
@@ -178,6 +184,14 @@ def version_callback(value: bool) -> None:
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
def show_secrets_callback(value: bool) -> None:
|
||||
"""Handle --show-secrets flag by updating the global redaction state."""
|
||||
if value:
|
||||
from cleveragents.shared.redaction import set_show_secrets
|
||||
|
||||
set_show_secrets(True)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main_callback(
|
||||
version: bool = typer.Option(
|
||||
@@ -187,6 +201,13 @@ def main_callback(
|
||||
is_eager=True,
|
||||
help="Show version",
|
||||
),
|
||||
show_secrets: bool = typer.Option(
|
||||
False,
|
||||
"--show-secrets",
|
||||
callback=show_secrets_callback,
|
||||
is_eager=True,
|
||||
help="Reveal secrets in CLI output (default: masked)",
|
||||
),
|
||||
) -> None:
|
||||
"""CleverAgents - AI-powered development assistant."""
|
||||
_register_subcommands()
|
||||
@@ -498,6 +519,7 @@ def main(args: list[str] | None = None) -> int:
|
||||
"action", # v3 plan lifecycle actions
|
||||
"resource", # Resource registry management
|
||||
"skill", # Skill management
|
||||
"cleanup", # Garbage collection and cleanup
|
||||
"auto-debug", # Auto-debug commands
|
||||
"tell", # Shortcut for plan tell
|
||||
"build", # Shortcut for plan build
|
||||
@@ -540,11 +562,14 @@ def main(args: list[str] | None = None) -> int:
|
||||
except typer.Abort:
|
||||
return 1
|
||||
except CleverAgentsError as e:
|
||||
from cleveragents.shared.redaction import redact_dict, redact_value
|
||||
|
||||
err_console = get_err_console()
|
||||
err_console.print(f"[red]Error:[/red] {e.message}")
|
||||
err_console.print(f"[red]Error:[/red] {redact_value(e.message)}")
|
||||
if e.details:
|
||||
safe_details = redact_dict(e.details)
|
||||
err_console.print("[dim]Details:[/dim]")
|
||||
for key, value in e.details.items():
|
||||
for key, value in safe_details.items():
|
||||
err_console.print(f" {key}: {value}")
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Structured logging configuration with secrets masking.
|
||||
|
||||
Configures ``structlog`` with the :func:`secrets_masking_processor`
|
||||
from :mod:`cleveragents.shared.redaction` inserted into the processor
|
||||
chain so that every log event is automatically redacted before
|
||||
rendering.
|
||||
|
||||
Usage::
|
||||
|
||||
from cleveragents.config.logging import configure_structlog, get_logger
|
||||
|
||||
configure_structlog(env="development", log_level="DEBUG")
|
||||
logger = get_logger(__name__)
|
||||
logger.info("connected", api_key="sk-proj-secret123")
|
||||
# api_key will appear as ***REDACTED*** in output
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.shared.redaction import secrets_masking_processor
|
||||
|
||||
__all__ = ["configure_structlog", "get_logger"]
|
||||
|
||||
|
||||
def configure_structlog(
|
||||
*,
|
||||
env: str = "development",
|
||||
log_level: str = "INFO",
|
||||
) -> None:
|
||||
"""Configure structlog with secrets masking.
|
||||
|
||||
Args:
|
||||
env: Runtime environment name. When ``"production"`` the JSON
|
||||
renderer is used; otherwise the coloured console renderer.
|
||||
log_level: Minimum log level (e.g. ``"DEBUG"``, ``"INFO"``).
|
||||
|
||||
Raises:
|
||||
ValueError: If *log_level* is not a recognised level name.
|
||||
"""
|
||||
numeric_level = getattr(logging, log_level.upper(), None)
|
||||
if not isinstance(numeric_level, int):
|
||||
raise ValueError(f"Invalid log level: {log_level!r}")
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=numeric_level, force=True)
|
||||
|
||||
renderer: structlog.types.Processor
|
||||
if env.lower() == "production":
|
||||
renderer = structlog.processors.JSONRenderer()
|
||||
else:
|
||||
renderer = structlog.dev.ConsoleRenderer()
|
||||
|
||||
shared_processors: list[structlog.types.Processor] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
secrets_masking_processor,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=shared_processors,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
renderer,
|
||||
],
|
||||
)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers:
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> Any:
|
||||
"""Return a structlog bound logger.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically ``__name__``).
|
||||
|
||||
Returns:
|
||||
A structlog ``BoundLogger`` instance.
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
@@ -148,6 +148,46 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_STORAGE_BASE_PATH"),
|
||||
)
|
||||
|
||||
# Cleanup / retention policies (CONC3)
|
||||
cleanup_sandbox_max_age_hours: int = Field(
|
||||
default=48,
|
||||
ge=1,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CLEANUP_SANDBOX_MAX_AGE_HOURS"),
|
||||
description="Max age (hours) before stale sandboxes are eligible for cleanup.",
|
||||
)
|
||||
cleanup_checkpoint_max_per_plan: int = Field(
|
||||
default=50,
|
||||
ge=2,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CHECKPOINT_MAX"),
|
||||
description=(
|
||||
"Max checkpoints per plan; oldest pruned first (keep first + most recent). "
|
||||
"Minimum 2 to always preserve the first and most recent checkpoints."
|
||||
),
|
||||
)
|
||||
cleanup_session_inactivity_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CLEANUP_SESSION_INACTIVITY_DAYS"),
|
||||
description="Days of inactivity before a session is eligible for cleanup.",
|
||||
)
|
||||
cleanup_log_retention_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_LOG_RETENTION_DAYS"),
|
||||
description="Days to retain log files before automatic cleanup.",
|
||||
)
|
||||
cleanup_backup_retention_days: int = Field(
|
||||
default=7,
|
||||
ge=1,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_BACKUP_RETENTION_DAYS"),
|
||||
description="Days to retain backup snapshots before automatic cleanup.",
|
||||
)
|
||||
cleanup_schedule: str = Field(
|
||||
default="manual",
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CLEANUP_SCHEDULE"),
|
||||
description="Cleanup schedule: 'manual' (MVP default) or 'auto'.",
|
||||
)
|
||||
|
||||
# Persistence
|
||||
database_url: str = Field(
|
||||
default="sqlite:///cleveragents.db",
|
||||
@@ -284,6 +324,26 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# Secrets visibility (SEC5)
|
||||
show_secrets: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_SHOW_SECRETS"),
|
||||
description="When True, secrets are shown in CLI output and logs.",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Safe repr that masks sensitive field values."""
|
||||
from cleveragents.shared.redaction import REDACTED, is_sensitive_key
|
||||
|
||||
parts: list[str] = []
|
||||
for field_name in self.model_fields:
|
||||
value = getattr(self, field_name, None)
|
||||
if is_sensitive_key(field_name) and value is not None:
|
||||
parts.append(f"{field_name}={REDACTED!r}")
|
||||
else:
|
||||
parts.append(f"{field_name}={value!r}")
|
||||
return f"Settings({', '.join(parts)})"
|
||||
|
||||
def model_post_init(self, __context: Any) -> None: # type: ignore[override]
|
||||
# pydantic v1 compatibility: BaseSettings may not define model_post_init
|
||||
maybe_super = getattr(super(), "model_post_init", None)
|
||||
|
||||
@@ -4,10 +4,33 @@ This module contains utilities and helpers that can be used
|
||||
across all layers without violating architectural boundaries.
|
||||
|
||||
Includes:
|
||||
- Secrets redaction and masking
|
||||
- Logging utilities
|
||||
- Metrics collection
|
||||
- Validation helpers
|
||||
- Date/time utilities
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
from cleveragents.shared.redaction import (
|
||||
REDACTED,
|
||||
get_show_secrets,
|
||||
is_sensitive_key,
|
||||
mask_database_url,
|
||||
redact_dict,
|
||||
redact_value,
|
||||
register_pattern,
|
||||
secrets_masking_processor,
|
||||
set_show_secrets,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"REDACTED",
|
||||
"get_show_secrets",
|
||||
"is_sensitive_key",
|
||||
"mask_database_url",
|
||||
"redact_dict",
|
||||
"redact_value",
|
||||
"register_pattern",
|
||||
"secrets_masking_processor",
|
||||
"set_show_secrets",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Centralized secrets redaction utility.
|
||||
|
||||
Provides pattern-based detection and masking of sensitive values
|
||||
such as API keys, tokens, and credentials. Integrated into CLI
|
||||
output, structlog processors, and error detail formatting.
|
||||
|
||||
Based on specification section "Secret Management" (line 27427):
|
||||
- Patterns: ``sk-``, ``sk-ant-``, ``tok_``, bearer tokens
|
||||
- Replacement: ``***REDACTED***``
|
||||
- Applied at structlog processor level and CLI output
|
||||
|
||||
See Also:
|
||||
- ``docs/reference/secrets_handling.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Any
|
||||
|
||||
REDACTED = "***REDACTED***"
|
||||
|
||||
# ── Sensitive key-name substrings ──────────────────────────────
|
||||
|
||||
_SENSITIVE_SUBSTRINGS: set[str] = {
|
||||
"api_key",
|
||||
"apikey",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"private_key",
|
||||
"access_key",
|
||||
"auth",
|
||||
}
|
||||
|
||||
# Keys whose *entire* name (case-insensitive) is non-sensitive even
|
||||
# though they contain a sensitive substring (e.g. "token_count").
|
||||
_FALSE_POSITIVE_KEYS: set[str] = {
|
||||
"token_count",
|
||||
"token_limit",
|
||||
"token_usage",
|
||||
"max_tokens",
|
||||
"total_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"token_estimate",
|
||||
"auth_method",
|
||||
"auth_type",
|
||||
"auth_enabled",
|
||||
}
|
||||
|
||||
# ── Secret value patterns (compiled) ──────────────────────────
|
||||
|
||||
_SECRET_PATTERNS: list[re.Pattern[str]] = [
|
||||
# OpenAI keys: sk-proj-..., sk-...
|
||||
re.compile(r"sk-(?:proj-)?[A-Za-z0-9_-]{10,}"),
|
||||
# Anthropic keys: sk-ant-api03-...
|
||||
re.compile(r"sk-ant-[A-Za-z0-9_-]{10,}"),
|
||||
# Token IDs: tok_...
|
||||
re.compile(r"tok_[A-Za-z0-9]{10,}"),
|
||||
# Bearer tokens
|
||||
re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]{20,}"),
|
||||
# Generic long hex/base64 keys (40+ chars)
|
||||
re.compile(r"(?:key|KEY)-[A-Za-z0-9]{20,}"),
|
||||
]
|
||||
|
||||
# Lock for thread-safe custom pattern registration
|
||||
_patterns_lock = threading.Lock()
|
||||
|
||||
# ── Global show_secrets flag ──────────────────────────────────
|
||||
|
||||
_show_secrets: bool = False
|
||||
_flag_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_show_secrets() -> bool:
|
||||
"""Return the current global show_secrets flag."""
|
||||
with _flag_lock:
|
||||
return _show_secrets
|
||||
|
||||
|
||||
def set_show_secrets(value: bool) -> None:
|
||||
"""Set the global show_secrets flag.
|
||||
|
||||
Args:
|
||||
value: Whether to reveal secrets in output.
|
||||
|
||||
Raises:
|
||||
TypeError: If value is not a bool.
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise TypeError(f"show_secrets must be bool, got {type(value).__name__}")
|
||||
global _show_secrets
|
||||
with _flag_lock:
|
||||
_show_secrets = value
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_sensitive_key(key: str) -> bool:
|
||||
"""Check whether a key name indicates a secret value.
|
||||
|
||||
Args:
|
||||
key: The field or key name to check.
|
||||
|
||||
Returns:
|
||||
True if the key name suggests a sensitive value.
|
||||
"""
|
||||
if not key:
|
||||
return False
|
||||
lower = key.lower()
|
||||
if lower in _FALSE_POSITIVE_KEYS:
|
||||
return False
|
||||
return any(sub in lower for sub in _SENSITIVE_SUBSTRINGS)
|
||||
|
||||
|
||||
def redact_value(value: str) -> str:
|
||||
"""Scan a string for secret patterns and replace matches.
|
||||
|
||||
Args:
|
||||
value: The string to scan and redact.
|
||||
|
||||
Returns:
|
||||
The string with any detected secrets replaced by
|
||||
``***REDACTED***``.
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
result = value
|
||||
with _patterns_lock:
|
||||
patterns = list(_SECRET_PATTERNS)
|
||||
for pattern in patterns:
|
||||
result = pattern.sub(REDACTED, result)
|
||||
return result
|
||||
|
||||
|
||||
def redact_dict(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
show_secrets: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Recursively redact sensitive keys and values in a dict.
|
||||
|
||||
Args:
|
||||
data: The dictionary to redact.
|
||||
show_secrets: Override for the global flag. When ``None``
|
||||
(default), uses the global ``get_show_secrets()`` value.
|
||||
|
||||
Returns:
|
||||
A new dict with sensitive values replaced by
|
||||
``***REDACTED***``.
|
||||
"""
|
||||
if show_secrets is None:
|
||||
show_secrets = get_show_secrets()
|
||||
if show_secrets:
|
||||
return dict(data)
|
||||
return _redact_dict_inner(data)
|
||||
|
||||
|
||||
def _redact_dict_inner(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursive implementation of dict redaction."""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if is_sensitive_key(key):
|
||||
result[key] = REDACTED
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _redact_dict_inner(value)
|
||||
elif isinstance(value, str):
|
||||
result[key] = redact_value(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_redact_dict_inner(item)
|
||||
if isinstance(item, dict)
|
||||
else (redact_value(item) if isinstance(item, str) else item)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def mask_database_url(url: str) -> str:
|
||||
"""Mask credentials in a database URL.
|
||||
|
||||
SQLite URLs (no credentials) are returned unchanged. URLs
|
||||
with ``user:password@host`` patterns have the password
|
||||
replaced.
|
||||
|
||||
Args:
|
||||
url: A SQLAlchemy-style database URL.
|
||||
|
||||
Returns:
|
||||
The URL with any embedded password masked.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
if url.startswith("sqlite"):
|
||||
return url
|
||||
# Pattern: scheme://user:password@host...
|
||||
masked = re.sub(
|
||||
r"(://[^:]+:)([^@]+)(@)",
|
||||
r"\1***\3",
|
||||
url,
|
||||
)
|
||||
return masked
|
||||
|
||||
|
||||
def register_pattern(pattern: str) -> None:
|
||||
"""Register a custom secret detection regex pattern.
|
||||
|
||||
Args:
|
||||
pattern: A regular expression string to match secrets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the pattern is empty.
|
||||
re.error: If the pattern is invalid regex.
|
||||
"""
|
||||
if not pattern:
|
||||
raise ValueError("pattern cannot be empty")
|
||||
compiled = re.compile(pattern)
|
||||
with _patterns_lock:
|
||||
_SECRET_PATTERNS.append(compiled)
|
||||
|
||||
|
||||
# ── structlog processor ───────────────────────────────────────
|
||||
|
||||
|
||||
def secrets_masking_processor(
|
||||
logger: Any,
|
||||
method_name: str,
|
||||
event_dict: MutableMapping[str, Any],
|
||||
) -> MutableMapping[str, Any]:
|
||||
"""structlog processor that redacts secrets from log events.
|
||||
|
||||
Walks all values in the event dict and applies pattern-based
|
||||
redaction. Intended to be inserted into the structlog
|
||||
processor chain.
|
||||
|
||||
Args:
|
||||
logger: The wrapped logger object (unused).
|
||||
method_name: The name of the log method (unused).
|
||||
event_dict: The structured log event dictionary.
|
||||
|
||||
Returns:
|
||||
The event dict with secrets masked.
|
||||
"""
|
||||
if get_show_secrets():
|
||||
return event_dict
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in event_dict.items():
|
||||
if is_sensitive_key(key):
|
||||
result[key] = REDACTED
|
||||
elif isinstance(value, str):
|
||||
result[key] = redact_value(value)
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _redact_dict_inner(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -128,3 +128,6 @@ get_parents # noqa: B018, F821
|
||||
auto_discover_children # noqa: B018, F821
|
||||
_get_ancestors # noqa: B018, F821
|
||||
_build_cycle_path # noqa: B018, F821
|
||||
|
||||
# structlog processor signature — method_name required by processor protocol
|
||||
method_name # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user