Merge pull request #281 from scorp323/oracle/batch-b-safe-hardening-20260703

fix: harden tool governance and subprocess safety
This commit is contained in:
Calesthio
2026-07-06 18:17:08 -07:00
committed by GitHub
6 changed files with 222 additions and 21 deletions

View File

@@ -164,11 +164,26 @@ class SceneDetect(BaseTool):
return scenes
@staticmethod
def _escape_lavfi_movie_path(path: str) -> str:
"""Escape a path for FFmpeg lavfi movie=... without allowing filter injection."""
normalized = path.replace("\\", "/")
if "'" in normalized:
raise ValueError("FFmpeg lavfi movie paths containing single quotes are unsupported")
escaped = []
for char in normalized:
if char in "\\:,[];":
escaped.append("\\" + char)
else:
escaped.append(char)
return "".join(escaped)
def _detect_ffmpeg(self, inputs: dict[str, Any]) -> list[dict]:
"""Fallback: use FFmpeg scene change filter."""
input_path = str(inputs["input_path"])
threshold = inputs.get("threshold", 0.3)
min_scene_len = inputs.get("min_scene_length_seconds", 1.0)
escaped_input = self._escape_lavfi_movie_path(input_path)
cmd = [
"ffprobe",
@@ -176,7 +191,7 @@ class SceneDetect(BaseTool):
"-show_entries", "frame=pts_time",
"-of", "json",
"-f", "lavfi",
f"movie='{input_path.replace(chr(92), '/').replace(':', chr(92)+':')}',select='gt(scene,{threshold})'",
f"movie='{escaped_input}',select='gt(scene,{threshold})'",
]
try:

View File

@@ -304,8 +304,9 @@ class BaseTool(ABC):
def check_dependencies(self) -> None:
"""Verify all dependencies are installed. Raises DependencyError if not."""
for dep in self.dependencies:
if dep.startswith("cmd:"):
cmd_name = dep[4:]
if dep.startswith(("cmd:", "binary:")):
prefix = "cmd:" if dep.startswith("cmd:") else "binary:"
cmd_name = dep[len(prefix):]
if shutil.which(cmd_name) is None:
raise DependencyError(
f"Command {cmd_name!r} not found. {self.install_instructions}"
@@ -424,20 +425,54 @@ class BaseTool(ABC):
exe = shutil.which(resolved_cmd[0])
if exe:
resolved_cmd[0] = exe
return subprocess.run(
resolved_cmd,
capture_output=True,
text=True,
# Force UTF-8 decoding. The default uses the OS locale (cp1252 on
# Windows), which raises UnicodeDecodeError on a subprocess that
# emits Unicode/emoji (e.g. Remotion's progress output), killing the
# reader thread and potentially swallowing the real error text.
encoding="utf-8",
errors="replace",
timeout=timeout,
cwd=cwd,
check=True,
)
try:
return subprocess.run(
resolved_cmd,
capture_output=True,
text=True,
# Force UTF-8 decoding. The default uses the OS locale (cp1252 on
# Windows), which raises UnicodeDecodeError on a subprocess that
# emits Unicode/emoji (e.g. Remotion's progress output), killing the
# reader thread and potentially swallowing the real error text.
encoding="utf-8",
errors="replace",
timeout=timeout,
cwd=cwd,
check=True,
)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
stdout = (exc.stdout or "").strip()
detail = stderr or stdout or str(exc)
raise ToolCommandError(
exc.returncode,
exc.cmd,
output=exc.output,
stderr=exc.stderr,
detail=detail,
) from exc
class ToolCommandError(subprocess.CalledProcessError):
"""CalledProcessError with stderr/stdout surfaced in str(error)."""
def __init__(
self,
returncode: int,
cmd: list[str],
*,
output: Optional[str] = None,
stderr: Optional[str] = None,
detail: str = "",
) -> None:
super().__init__(returncode, cmd, output=output, stderr=stderr)
self.detail = detail
def __str__(self) -> str:
base = super().__str__()
if self.detail:
return f"{base}\n{self.detail}"
return base
class DependencyError(Exception):

View File

@@ -141,11 +141,15 @@ class CostTracker:
# Check budget
if estimated > self.usable_budget_usd:
message = (
f"Reservation of ${estimated:.2f} exceeds usable budget "
f"${self.usable_budget_usd:.2f}"
)
if self.mode == BudgetMode.CAP:
raise BudgetExceededError(
f"Reservation of ${estimated:.2f} exceeds usable budget "
f"${self.usable_budget_usd:.2f}"
)
raise BudgetExceededError(message)
if self.mode == BudgetMode.WARN:
entry["budget_warning"] = True
entry["budget_warning_message"] = message
entry["status"] = EntryStatus.RESERVED.value
entry["reserved_usd"] = estimated
@@ -155,6 +159,7 @@ class CostTracker:
def approve_tool(self, tool: str) -> None:
"""Mark a tool as approved for paid operations."""
self._approved_tools.add(tool)
self._save()
def reconcile(self, entry_id: str, actual_usd: float, success: bool = True) -> None:
"""Reconcile actual spend after tool execution."""
@@ -487,6 +492,7 @@ class CostTracker:
"budget_total_usd": self.budget_total_usd,
"budget_reserved_usd": round(self.budget_reserved_usd, 4),
"budget_spent_usd": round(self.budget_spent_usd, 4),
"approved_tools": sorted(self._approved_tools),
"entries": self.entries,
}
self.cost_log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -498,6 +504,7 @@ class CostTracker:
data = json.load(f)
self.entries = data.get("entries", [])
self.budget_total_usd = data.get("budget_total_usd", self.budget_total_usd)
self._approved_tools = set(data.get("approved_tools", []))
# ---- Helpers ----