Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions agent_actions/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def execute(self, project_root: Path | None = None) -> None:
use_tools=self.args.use_tools,
run_upstream=self.args.upstream,
run_downstream=self.args.downstream,
fresh=self.args.fresh,
project_root=project_root,
)
)
Expand Down Expand Up @@ -187,6 +188,12 @@ def execute(self, project_root: Path | None = None) -> None:
is_flag=True,
help="Execute all downstream workflows that depend on this workflow",
)
@click.option(
"--fresh",
is_flag=True,
default=False,
help="Clear stored results and status before execution (useful after failed runs)",
)
@handles_user_errors("run")
@requires_project
def run(
Expand All @@ -197,6 +204,7 @@ def run(
concurrency_limit: int = 5,
upstream: bool = False,
downstream: bool = False,
fresh: bool = False,
project_root: Path | None = None,
) -> None:
"""
Expand All @@ -211,6 +219,7 @@ def run(
agac run -a my_agent --upstream
agac run -a my_agent --downstream
agac run -a my_agent --execution-mode parallel
agac run -a my_agent --fresh
"""
args = RunCommandArgs(
agent=agent,
Expand All @@ -220,6 +229,7 @@ def run(
concurrency_limit=concurrency_limit,
upstream=upstream,
downstream=downstream,
fresh=fresh,
)
command = RunCommand(args)
command.execute(project_root=project_root)
36 changes: 15 additions & 21 deletions agent_actions/prompt/context/static_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,14 @@ def _parse_file_path(self, file_spec: str, field_name: str) -> str:
return file_spec # Use as-is

def _resolve_path(self, file_path: str, field_name: str) -> Path:
"""Resolve file path relative to static_data_dir with security validation."""
"""Resolve file path relative to static_data_dir with security validation.

Delegates core traversal prevention to the shared ``resolve_seed_path``
utility and wraps any failure in a ``StaticDataLoadError`` with rich
context for diagnostics.
"""
from agent_actions.utils.path_security import resolve_seed_path

path = Path(file_path)

# Reject absolute paths immediately
Expand All @@ -140,40 +147,27 @@ def _resolve_path(self, file_path: str, field_name: str) -> Path:
},
)

# Resolve relative to static_data_dir
resolved = (self.static_data_dir / path).resolve()

# Validate security
self._validate_path_security(resolved, field_name, file_path)

logger.debug("Resolved path for field '%s': %s", field_name, resolved)
return resolved

def _validate_path_security(
self, resolved_path: Path, field_name: str, original_path: str
) -> None:
"""Validate that resolved path doesn't escape static_data_dir."""
try:
# This will raise ValueError if path is outside static_data_dir
resolved_path.relative_to(self.static_data_dir)
resolved = resolve_seed_path(file_path, self.static_data_dir)
except ValueError as exc:
logger.error(
"Path traversal attempt detected for field '%s': %s -> %s",
"Path traversal attempt detected for field '%s': %s",
field_name,
original_path,
resolved_path,
file_path,
)
raise StaticDataLoadError(
f"Static data field '{field_name}': File path escapes static data directory",
context={
"field_name": field_name,
"original_path": original_path,
"resolved_path": str(resolved_path),
"original_path": file_path,
"static_data_dir": str(self.static_data_dir),
"error_type": "path_traversal_attempt",
},
) from exc

logger.debug("Resolved path for field '%s': %s", field_name, resolved)
return resolved

def _load_file(self, file_path: Path, field_name: str) -> Any:
"""Load file content based on file extension."""
# Check if file exists
Expand Down
9 changes: 9 additions & 0 deletions agent_actions/storage/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ def clear_disposition(
"""Delete matching disposition records. Returns count deleted."""
return 0

def delete_target(self, action_name: str) -> int:
"""Delete all target data for an action. Returns count deleted.

Subclasses **must** override — the default raises so that backend
authors are forced to implement it and ``--fresh`` cannot silently
leave stale data behind.
"""
raise NotImplementedError(f"{type(self).__name__} must implement delete_target()")

def close(self) -> None: # noqa: B027
"""Close the storage backend and release resources."""
pass
Expand Down
29 changes: 29 additions & 0 deletions agent_actions/storage/backends/sqlite_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,35 @@ def clear_disposition(
)
raise

def delete_target(self, action_name: str) -> int:
"""Delete all target data for a specific action. Returns count deleted."""
action_name = self._validate_identifier(action_name, "action_name")
with self._lock:
cursor = self.connection.cursor()
try:
cursor.execute(
"DELETE FROM target_data WHERE action_name = ?",
(action_name,),
)
self.connection.commit()
deleted = cursor.rowcount
logger.debug(
"Deleted %d target records for %s",
deleted,
action_name,
extra={"workflow_name": self.workflow_name},
)
return deleted
except sqlite3.Error as e:
self.connection.rollback()
logger.error(
"Failed to delete target for %s: %s",
action_name,
e,
extra={"workflow_name": self.workflow_name},
)
raise

@staticmethod
def _format_size(size_bytes: int) -> str:
"""Format bytes as human-readable size."""
Expand Down
42 changes: 42 additions & 0 deletions agent_actions/utils/path_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Shared path security utilities for seed data resolution.

Both the pre-flight resolution service and the runtime StaticDataLoader
call ``resolve_seed_path`` so that path-traversal prevention logic exists
in exactly one place.
"""

from pathlib import Path

FILE_PREFIX = "$file:"


def resolve_seed_path(file_spec: str, base_dir: Path) -> Path:
"""Parse a ``$file:`` reference, resolve against *base_dir*, and validate.

Returns the resolved absolute ``Path``.

Raises:
ValueError: If the spec is empty, escapes *base_dir* via traversal,
or is otherwise invalid.
"""
if not file_spec:
raise ValueError("Empty file spec")

# Strip $file: prefix if present
file_path = file_spec[len(FILE_PREFIX) :] if file_spec.startswith(FILE_PREFIX) else file_spec

if not file_path:
raise ValueError(f"Empty path after prefix in: {file_spec}")

resolved = (base_dir / file_path).resolve()

# Security: prevent path traversal outside base_dir
try:
resolved.relative_to(base_dir.resolve())
except ValueError:
raise ValueError(
f"Seed file path escapes base directory: {file_spec} "
f"(resolved to {resolved}, base is {base_dir.resolve()})"
) from None

return resolved
Loading
Loading