refactor: delete orphaned dirs, dead streaming infra, unused session/compressor

Orphaned files/dirs:
- ``strix/agents/StrixAgent/`` — empty, only ``__pycache__``.
- ``strix/tools/browser/litellm/`` — empty, only ``__pycache__``.
- ``strix/strix_runs/`` — runtime output left in the working tree.
- ``strix/prompts/`` — single Jinja template that nothing renders.

Dead streaming pipeline (was never wired in the SDK migration):
- Delete ``strix/interface/streaming_parser.py`` (XML tool-call parser
  for an output format the SDK doesn't produce).
- Strip ``streaming_content`` / ``interrupted_content`` dicts and
  five unused methods from ``Tracer``.
- Strip the streaming-render path + ``interrupted`` branch from TUI.
- Trim ``strix/llm/utils.py``: drop ``normalize_tool_format``,
  ``parse_tool_invocations``, ``format_tool_call``,
  ``fix_incomplete_tool_call`` and the XML-stripping in
  ``clean_content``. Keep only the inter-agent-XML scrub.

Unwired session compression:
- Delete ``strix/llm/strix_session.py`` and
  ``strix/llm/memory_compressor.py``. ``Runner.run`` was never called
  with a ``session=``, so the compressor never ran. Drop the matching
  test file and the ``strix_memory_compressor_timeout`` config knob.

Tracer cleanup:
- Remove ``log_agent_creation``, ``log_tool_execution_start``,
  ``update_tool_execution``, ``update_agent_status``,
  ``get_agent_tools`` — none had production callers.
- Rewrite the redaction + correlation tests against
  ``log_chat_message`` (which still emits events).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 12:21:59 -07:00
parent 4146174503
commit 369fa56148
11 changed files with 33 additions and 1295 deletions
+3 -7
View File
@@ -1,12 +1,8 @@
"""Jinja-based system-prompt renderer.
Loads ``strix/agents/prompts/system_prompt.jinja`` (508 lines — the
multi-section production prompt with skills, tools, scan modes, etc.)
and renders it with the caller's per-run context (scan mode, whitebox,
interactive, scope authorization block).
References:
- HARNESS_WIKI.md §4.1 (system prompt assembly)
Loads ``strix/agents/prompts/system_prompt.jinja`` and renders it with
the caller's per-run context (skills, scan mode, whitebox flag,
interactive flag, scope authorization block).
"""
from __future__ import annotations
-2
View File
@@ -17,7 +17,6 @@ class Config:
ollama_api_base = None
strix_reasoning_effort = "high"
strix_llm_max_retries = "5"
strix_memory_compressor_timeout = "30"
llm_timeout = "300"
_LLM_CANONICAL_NAMES = (
"strix_llm",
@@ -28,7 +27,6 @@ class Config:
"ollama_api_base",
"strix_reasoning_effort",
"strix_llm_max_retries",
"strix_memory_compressor_timeout",
"llm_timeout",
)
-125
View File
@@ -1,125 +0,0 @@
import html
import re
from dataclasses import dataclass
from typing import Literal
from strix.llm.utils import normalize_tool_format
_FUNCTION_TAG_PREFIX = "<function="
_INVOKE_TAG_PREFIX = "<invoke "
_FUNC_PATTERN = re.compile(r"<function=([^>]+)>")
_FUNC_END_PATTERN = re.compile(r"</function>")
_COMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
_INCOMPLETE_PARAM_PATTERN = re.compile(r"<parameter=([^>]+)>(.*)$", re.DOTALL)
def _get_safe_content(content: str) -> tuple[str, str]:
if not content:
return "", ""
last_lt = content.rfind("<")
if last_lt == -1:
return content, ""
suffix = content[last_lt:]
if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix):
return content[:last_lt], suffix
return content, ""
@dataclass
class StreamSegment:
type: Literal["text", "tool"]
content: str
tool_name: str | None = None
args: dict[str, str] | None = None
is_complete: bool = False
def parse_streaming_content(content: str) -> list[StreamSegment]:
if not content:
return []
content = normalize_tool_format(content)
segments: list[StreamSegment] = []
func_matches = list(_FUNC_PATTERN.finditer(content))
if not func_matches:
safe_content, _ = _get_safe_content(content)
text = safe_content.strip()
if text:
segments.append(StreamSegment(type="text", content=text))
return segments
first_func_start = func_matches[0].start()
if first_func_start > 0:
text_before = content[:first_func_start].strip()
if text_before:
segments.append(StreamSegment(type="text", content=text_before))
for i, match in enumerate(func_matches):
tool_name = match.group(1)
func_start = match.end()
func_end_match = _FUNC_END_PATTERN.search(content, func_start)
if func_end_match:
func_body = content[func_start : func_end_match.start()]
is_complete = True
end_pos = func_end_match.end()
else:
if i + 1 < len(func_matches):
next_func_start = func_matches[i + 1].start()
func_body = content[func_start:next_func_start]
else:
func_body = content[func_start:]
is_complete = False
end_pos = len(content)
args = _parse_streaming_params(func_body)
segments.append(
StreamSegment(
type="tool",
content=func_body,
tool_name=tool_name,
args=args,
is_complete=is_complete,
)
)
if is_complete and i + 1 < len(func_matches):
next_start = func_matches[i + 1].start()
text_between = content[end_pos:next_start].strip()
if text_between:
segments.append(StreamSegment(type="text", content=text_between))
return segments
def _parse_streaming_params(func_body: str) -> dict[str, str]:
args: dict[str, str] = {}
complete_matches = list(_COMPLETE_PARAM_PATTERN.finditer(func_body))
complete_end_pos = 0
for match in complete_matches:
param_name = match.group(1)
param_value = html.unescape(match.group(2).strip())
args[param_name] = param_value
complete_end_pos = max(complete_end_pos, match.end())
remaining = func_body[complete_end_pos:]
incomplete_match = _INCOMPLETE_PARAM_PATTERN.search(remaining)
if incomplete_match:
param_name = incomplete_match.group(1)
param_value = html.unescape(incomplete_match.group(2).strip())
args[param_name] = param_value
return args
+4 -126
View File
@@ -33,7 +33,6 @@ from textual.widgets.tree import TreeNode
from strix.config import Config
from strix.entry import run_strix_scan
from strix.interface.streaming_parser import parse_streaming_content
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
from strix.interface.tool_components.registry import get_tool_renderer
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
@@ -723,9 +722,6 @@ class StrixTUIApp(App): # type: ignore[misc]
self._displayed_agents: set[str] = set()
self._displayed_events: list[str] = []
self._streaming_render_cache: dict[str, tuple[int, Any]] = {}
self._last_streaming_len: dict[str, int] = {}
self._scan_thread: threading.Thread | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event()
@@ -979,25 +975,17 @@ class StrixTUIApp(App): # type: ignore[misc]
)
events = self._gather_agent_events(self.selected_agent_id)
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
if not events and not streaming:
if not events:
return self._get_chat_placeholder_content(
"Starting agent...", "placeholder-no-activity"
)
current_event_ids = [e["id"] for e in events]
current_streaming_len = len(streaming) if streaming else 0
last_streaming_len = self._last_streaming_len.get(self.selected_agent_id, 0)
if (
current_event_ids == self._displayed_events
and current_streaming_len == last_streaming_len
):
if current_event_ids == self._displayed_events:
return None, None
self._displayed_events = current_event_ids
self._last_streaming_len[self.selected_agent_id] = current_streaming_len
return self._get_rendered_events_content(events), "chat-content"
def _update_chat_view(self) -> None:
@@ -1106,15 +1094,6 @@ class StrixTUIApp(App): # type: ignore[misc]
renderables.append(Text(""))
renderables.append(content)
if self.selected_agent_id:
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
if streaming:
streaming_text = self._render_streaming_content(streaming)
if streaming_text:
if renderables:
renderables.append(Text(""))
renderables.append(streaming_text)
if not renderables:
return Text()
@@ -1123,85 +1102,6 @@ class StrixTUIApp(App): # type: ignore[misc]
return self._merge_renderables(renderables)
def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any:
cache_key = agent_id or self.selected_agent_id or ""
content_len = len(content)
if cache_key in self._streaming_render_cache:
cached_len, cached_output = self._streaming_render_cache[cache_key]
if cached_len == content_len:
return cached_output
renderables: list[Any] = []
segments = parse_streaming_content(content)
for segment in segments:
if segment.type == "text":
text_content = AgentMessageRenderer.render_simple(segment.content)
if renderables:
renderables.append(Text(""))
renderables.append(text_content)
elif segment.type == "tool":
tool_renderable = self._render_streaming_tool(
segment.tool_name or "unknown",
segment.args or {},
segment.is_complete,
)
if renderables:
renderables.append(Text(""))
renderables.append(tool_renderable)
if not renderables:
result = Text()
elif len(renderables) == 1 and isinstance(renderables[0], Text):
result = self._sanitize_text(renderables[0])
else:
result = self._merge_renderables(renderables)
self._streaming_render_cache[cache_key] = (content_len, result)
return result
def _render_streaming_tool(
self, tool_name: str, args: dict[str, str], is_complete: bool
) -> Any:
tool_data = {
"tool_name": tool_name,
"args": args,
"status": "completed" if is_complete else "running",
"result": None,
}
renderer = get_tool_renderer(tool_name)
if renderer:
widget = renderer.render(tool_data)
return widget.content
return self._render_default_streaming_tool(tool_name, args, is_complete)
def _render_default_streaming_tool(
self, tool_name: str, args: dict[str, str], is_complete: bool
) -> Text:
text = Text()
if is_complete:
text.append("", style="green")
else:
text.append("", style="yellow")
text.append("Using tool ", style="dim")
text.append(tool_name, style="bold blue")
if args:
for key, value in list(args.items())[:3]:
text.append("\n ")
text.append(key, style="dim")
text.append(": ")
display_value = value if len(value) <= 100 else value[:97] + "..."
text.append(display_value, style="italic" if not is_complete else None)
return text
def _get_status_display_content(
self, agent_id: str, agent_data: dict[str, Any]
) -> tuple[Text | None, Text, bool]:
@@ -1442,8 +1342,7 @@ class StrixTUIApp(App): # type: ignore[misc]
if tool_name not in initial_tools:
return True
streaming = self.tracer.get_streaming_content(agent_id)
return bool(streaming and streaming.strip())
return False
def _agent_vulnerability_count(self, agent_id: str) -> int:
count = 0
@@ -1493,8 +1392,6 @@ class StrixTUIApp(App): # type: ignore[misc]
return
self._displayed_events.clear()
self._streaming_render_cache.clear()
self._last_streaming_len.clear()
self.call_later(self._update_chat_view)
self._update_agent_status_display()
@@ -1710,17 +1607,10 @@ class StrixTUIApp(App): # type: ignore[misc]
if not content:
return None
del metadata
if role == "user":
return UserMessageRenderer.render_simple(content)
if metadata.get("interrupted"):
streaming_result = self._render_streaming_content(content)
interrupted_text = Text()
interrupted_text.append("\n")
interrupted_text.append("", style="yellow")
interrupted_text.append("Interrupted by user", style="yellow dim")
return self._merge_renderables([streaming_result, interrupted_text])
return AgentMessageRenderer.render_simple(content)
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:
@@ -1828,18 +1718,6 @@ class StrixTUIApp(App): # type: ignore[misc]
if not self.selected_agent_id:
return
if self.tracer:
streaming_content = self.tracer.get_streaming_content(self.selected_agent_id)
if streaming_content and streaming_content.strip():
self.tracer.clear_streaming_content(self.selected_agent_id)
self.tracer.interrupted_content[self.selected_agent_id] = streaming_content
self.tracer.log_chat_message(
content=streaming_content,
role="assistant",
agent_id=self.selected_agent_id,
metadata={"interrupted": True},
)
if self.tracer:
self.tracer.log_chat_message(
content=message,
-219
View File
@@ -1,219 +0,0 @@
import logging
from typing import Any
import litellm
from strix.config.config import Config, resolve_llm_config
logger = logging.getLogger(__name__)
MAX_TOTAL_TOKENS = 100_000
MIN_RECENT_MESSAGES = 15
SUMMARY_PROMPT_TEMPLATE = """You are an agent performing context
condensation for a security agent. Your job is to compress scan data while preserving
ALL operationally critical information for continuing the security assessment.
CRITICAL ELEMENTS TO PRESERVE:
- Discovered vulnerabilities and potential attack vectors
- Scan results and tool outputs (compressed but maintaining key findings)
- Access credentials, tokens, or authentication details found
- System architecture insights and potential weak points
- Progress made in the assessment
- Failed attempts and dead ends (to avoid duplication)
- Any decisions made about the testing approach
COMPRESSION GUIDELINES:
- Preserve exact technical details (URLs, paths, parameters, payloads)
- Summarize verbose tool outputs while keeping critical findings
- Maintain version numbers, specific technologies identified
- Keep exact error messages that might indicate vulnerabilities
- Compress repetitive or similar findings into consolidated form
Remember: Another security agent will use this summary to continue the assessment.
They must be able to pick up exactly where you left off without losing any
operational advantage or context needed to find vulnerabilities.
CONVERSATION SEGMENT TO SUMMARIZE:
{conversation}
Provide a technically precise summary that preserves all operational security context while
keeping the summary concise and to the point."""
def _count_tokens(text: str, model: str) -> int:
try:
count = litellm.token_counter(model=model, text=text)
return int(count)
except Exception:
logger.exception("Failed to count tokens")
return len(text) // 4 # Rough estimate
def _get_message_tokens(msg: dict[str, Any], model: str) -> int:
content = msg.get("content", "")
if isinstance(content, str):
return _count_tokens(content, model)
if isinstance(content, list):
return sum(
_count_tokens(item.get("text", ""), model)
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return 0
def _extract_message_text(msg: dict[str, Any]) -> str:
content = msg.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(item.get("text", ""))
elif item.get("type") == "image_url":
parts.append("[IMAGE]")
return " ".join(parts)
return str(content)
def _summarize_messages(
messages: list[dict[str, Any]],
model: str,
timeout: int = 30,
) -> dict[str, Any]:
if not messages:
empty_summary = "<context_summary message_count='0'>{text}</context_summary>"
return {
"role": "user",
"content": empty_summary.format(text="No messages to summarize"),
}
formatted = []
for msg in messages:
role = msg.get("role", "unknown")
text = _extract_message_text(msg)
formatted.append(f"{role}: {text}")
conversation = "\n".join(formatted)
prompt = SUMMARY_PROMPT_TEMPLATE.format(conversation=conversation)
_, api_key, api_base = resolve_llm_config()
try:
completion_args: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"timeout": timeout,
}
if api_key:
completion_args["api_key"] = api_key
if api_base:
completion_args["api_base"] = api_base
response = litellm.completion(**completion_args)
summary = response.choices[0].message.content or ""
if not summary.strip():
return messages[0]
summary_msg = "<context_summary message_count='{count}'>{text}</context_summary>"
return {
"role": "user",
"content": summary_msg.format(count=len(messages), text=summary),
}
except Exception:
logger.exception("Failed to summarize messages")
return messages[0]
def _handle_images(messages: list[dict[str, Any]], max_images: int) -> None:
image_count = 0
for msg in reversed(messages):
content = msg.get("content", [])
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image_url":
if image_count >= max_images:
item.update(
{
"type": "text",
"text": "[Previously attached image removed to preserve context]",
}
)
else:
image_count += 1
class MemoryCompressor:
def __init__(
self,
max_images: int = 3,
model_name: str | None = None,
timeout: int | None = None,
):
self.max_images = max_images
self.model_name = model_name or Config.get("strix_llm")
self.timeout = timeout or int(Config.get("strix_memory_compressor_timeout") or "120")
if not self.model_name:
raise ValueError("STRIX_LLM environment variable must be set and not empty")
def compress_history(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Compress conversation history to stay within token limits.
Strategy:
1. Handle image limits first
2. Keep all system messages
3. Keep minimum recent messages
4. Summarize older messages when total tokens exceed limit
The compression preserves:
- All system messages unchanged
- Most recent messages intact
- Critical security context in summaries
- Recent images for visual context
- Technical details and findings
"""
if not messages:
return messages
_handle_images(messages, self.max_images)
system_msgs = []
regular_msgs = []
for msg in messages:
if msg.get("role") == "system":
system_msgs.append(msg)
else:
regular_msgs.append(msg)
recent_msgs = regular_msgs[-MIN_RECENT_MESSAGES:]
old_msgs = regular_msgs[:-MIN_RECENT_MESSAGES]
# Type assertion since we ensure model_name is not None in __init__
model_name: str = self.model_name # type: ignore[assignment]
total_tokens = sum(
_get_message_tokens(msg, model_name) for msg in system_msgs + regular_msgs
)
if total_tokens <= MAX_TOTAL_TOKENS * 0.9:
return messages
compressed = []
chunk_size = 10
for i in range(0, len(old_msgs), chunk_size):
chunk = old_msgs[i : i + chunk_size]
summary = _summarize_messages(chunk, model_name, self.timeout)
if summary:
compressed.append(summary)
return system_msgs + compressed + recent_msgs
-99
View File
@@ -1,99 +0,0 @@
"""``StrixSession`` — Session wrapper that runs the MemoryCompressor.
Delegates storage to any underlying session implementation (in-memory,
SQLite, Redis, ) and intercepts ``get_items`` so the
``MemoryCompressor`` runs before the model sees the history.
Wrapping (rather than reimplementing) keeps the pentest-tuned
summarization prompt and 90K-token budget intact. ``get_items`` is
also the last hook before ``call_model_input_filter``, so compressing
here means the filter sees a compressed history too.
If compression raises, we fall back to the uncompressed history and
flip a flag so future attempts skip a permanently broken compressor
mustn't infinite-loop the agent into context starvation.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
from agents.memory.session import SessionABC
if TYPE_CHECKING:
from agents.items import TResponseInputItem
from strix.llm.memory_compressor import MemoryCompressor
logger = logging.getLogger(__name__)
class StrixSession(SessionABC):
"""Wraps an underlying ``SessionABC`` with Strix's memory compressor.
The wrapped session owns persistence; ``StrixSession`` only intercepts
``get_items`` to run compression. Writes (``add_items``, ``pop_item``,
``clear_session``) pass through verbatim.
On compressor failure, the call returns the uncompressed history and
a per-instance flag is set so subsequent ``get_items`` calls skip the
compressor entirely. This avoids an infinite "compress → fail → grow"
loop when the compressor LLM is itself unavailable.
"""
def __init__(
self,
underlying: SessionABC,
compressor: MemoryCompressor,
) -> None:
self._underlying = underlying
self._compressor = compressor
self._compression_disabled = False
# ``SessionABC.session_id`` is a plain ``str`` field; pass through.
self.session_id: str = getattr(underlying, "session_id", "strix-session")
self.session_settings = getattr(underlying, "session_settings", None)
@property
def compression_disabled(self) -> bool:
"""True after the compressor has failed at least once on this session."""
return self._compression_disabled
async def get_items(
self,
limit: int | None = None,
) -> list[TResponseInputItem]:
"""Read items from underlying storage and (optionally) compress.
On any compressor exception, log and return the uncompressed list.
Set ``_compression_disabled`` so the next call short-circuits.
"""
items = await self._underlying.get_items(limit=limit)
if self._compression_disabled or not items:
return items
try:
# Compressor expects ``list[dict[str, Any]]``; SDK's
# ``TResponseInputItem`` is a TypedDict union — structurally
# compatible. Compressor mutates content but preserves shape.
compressed = self._compressor.compress_history(
cast("list[dict[str, Any]]", items),
)
return cast("list[TResponseInputItem]", compressed)
except Exception:
logger.exception(
"MemoryCompressor failed; returning uncompressed history. "
"Compression disabled for this session for the rest of the run.",
)
self._compression_disabled = True
return items
async def add_items(self, items: list[TResponseInputItem]) -> None:
await self._underlying.add_items(items)
async def pop_item(self) -> TResponseInputItem | None:
return await self._underlying.pop_item()
async def clear_session(self) -> None:
await self._underlying.clear_session()
+15 -116
View File
@@ -1,127 +1,26 @@
"""Streaming + tool-format helpers used by the TUI's render pipeline.
"""Helpers for the TUI message renderer.
The model can emit tool calls in a few XML shapes (``<function=>``,
``<invoke name="">``, optionally wrapped in ``<function_calls>``); the
streaming parser normalizes them into one canonical form so the
renderer doesn't have to branch.
These helpers are pure string manipulation no model client, no SDK
dependency. They live here because the streaming parser and the
agent-message renderer both consume them.
The model occasionally echoes inter-agent XML envelopes in plain text
despite the system prompt's "don't echo" rule, so :func:`clean_content`
strips them defensively before display.
"""
import html
import re
from typing import Any
_INVOKE_OPEN = re.compile(r'<invoke\s+name=["\']([^"\']+)["\']>')
_PARAM_NAME_ATTR = re.compile(r'<parameter\s+name=["\']([^"\']+)["\']>')
_FUNCTION_CALLS_TAG = re.compile(r"</?function_calls>")
_STRIP_TAG_QUOTES = re.compile(r"<(function|parameter)\s*=\s*([^>]*?)>")
def normalize_tool_format(content: str) -> str:
"""Convert alternative tool-call XML formats to the expected one.
Handles:
<function_calls>...</function_calls> stripped
<invoke name="X"> <function=X>
<parameter name="X"> <parameter=X>
</invoke> </function>
<function="X"> <function=X>
<parameter="X"> <parameter=X>
"""
if "<invoke" in content or "<function_calls" in content:
content = _FUNCTION_CALLS_TAG.sub("", content)
content = _INVOKE_OPEN.sub(r"<function=\1>", content)
content = _PARAM_NAME_ATTR.sub(r"<parameter=\1>", content)
content = content.replace("</invoke>", "</function>")
return _STRIP_TAG_QUOTES.sub(
lambda m: f"<{m.group(1)}={m.group(2).strip().strip(chr(34) + chr(39))}>",
content,
)
def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_invocations: list[dict[str, Any]] = []
fn_regex_pattern = r"<function=([^>]+)>\n?(.*?)</function>"
fn_param_regex_pattern = r"<parameter=([^>]+)>(.*?)</parameter>"
fn_matches = re.finditer(fn_regex_pattern, content, re.DOTALL)
for fn_match in fn_matches:
fn_name = fn_match.group(1)
fn_body = fn_match.group(2)
param_matches = re.finditer(fn_param_regex_pattern, fn_body, re.DOTALL)
args = {}
for param_match in param_matches:
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
param_value = html.unescape(param_value)
args[param_name] = param_value
tool_invocations.append({"toolName": fn_name, "args": args})
return tool_invocations if tool_invocations else None
def fix_incomplete_tool_call(content: str) -> str:
"""Fix incomplete tool calls by adding missing closing tag.
Handles both ``<function=>`` and ``<invoke name="">`` formats.
"""
has_open = "<function=" in content or "<invoke " in content
count_open = content.count("<function=") + content.count("<invoke ")
has_close = "</function>" in content or "</invoke>" in content
if has_open and count_open == 1 and not has_close:
content = content.rstrip()
content = content + "function>" if content.endswith("</") else content + "\n</function>"
return content
def format_tool_call(tool_name: str, args: dict[str, Any]) -> str:
xml_parts = [f"<function={tool_name}>"]
for key, value in args.items():
xml_parts.append(f"<parameter={key}>{value}</parameter>")
xml_parts.append("</function>")
return "\n".join(xml_parts)
_HIDDEN_XML_PATTERNS = [
re.compile(r"<inter_agent_message>.*?</inter_agent_message>", re.DOTALL | re.IGNORECASE),
re.compile(
r"<agent_completion_report>.*?</agent_completion_report>",
re.DOTALL | re.IGNORECASE,
),
]
_BLANK_LINE_RUNS = re.compile(r"\n\s*\n")
def clean_content(content: str) -> str:
if not content:
return ""
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content)
tool_pattern = r"<function=[^>]+>.*?</function>"
cleaned = re.sub(tool_pattern, "", content, flags=re.DOTALL)
incomplete_tool_pattern = r"<function=[^>]+>.*$"
cleaned = re.sub(incomplete_tool_pattern, "", cleaned, flags=re.DOTALL)
partial_tag_pattern = r"<f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=(?:[^>]*)?)?)?)?)?)?)?)?)?$"
cleaned = re.sub(partial_tag_pattern, "", cleaned)
hidden_xml_patterns = [
r"<inter_agent_message>.*?</inter_agent_message>",
r"<agent_completion_report>.*?</agent_completion_report>",
]
for pattern in hidden_xml_patterns:
cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL | re.IGNORECASE)
cleaned = re.sub(r"\n\s*\n", "\n\n", cleaned)
return cleaned.strip()
for pattern in _HIDDEN_XML_PATTERNS:
content = pattern.sub("", content)
return _BLANK_LINE_RUNS.sub("\n\n", content).strip()
@@ -1,266 +0,0 @@
<nosql_injection_guide>
<title>NoSQL INJECTION</title>
<critical>NoSQL injection exploits vulnerabilities in non-relational databases (MongoDB, CouchDB, Redis, Cassandra, etc.) where user input manipulates query logic, operators, or JavaScript execution contexts. Unlike SQL injection, NoSQL attacks often target JSON/BSON structures, query operators, and server-side JavaScript evaluation. Treat every user-controlled input destined for NoSQL queries as untrusted.</critical>
<scope>
- Document stores: MongoDB, CouchDB, Couchbase, Amazon DocumentDB
- Key-value stores: Redis, DynamoDB, Memcached
- Wide-column stores: Cassandra, HBase, ScyllaDB
- Graph databases: Neo4j, ArangoDB, Amazon Neptune
- Integration paths: ODMs (Mongoose, Morphia), REST APIs, GraphQL resolvers, serverless functions
</scope>
<methodology>
1. Identify NoSQL database type from error messages, response patterns, headers, or technology fingerprinting.
2. Determine input format: JSON body, query string, URL path, headers; note how input is parsed and merged into queries.
3. Test operator injection: inject MongoDB operators ($ne, $gt, $regex, $where) or database-specific syntax to alter query logic.
4. Establish extraction channel: boolean-based response diffs, timing via $where/JavaScript, regex-based character extraction, or error messages.
5. Pivot to authentication bypass, data exfiltration, or JavaScript execution depending on database capabilities.
</methodology>
<injection_surfaces>
- JSON body parameters: direct object/operator injection via nested objects or arrays
- Query string: array notation (?username[$ne]=) or JSON-encoded values
- URL path segments: document IDs, collection names in RESTful APIs
- Headers/cookies: session data parsed as JSON, JWT claims used in queries
- GraphQL variables: unvalidated input passed directly to resolvers
- Aggregation pipelines: $match, $lookup, $group stages with user-controlled fields
</injection_surfaces>
<detection_channels>
- Operator-based: inject query operators to modify predicate logic; test $gt/$gte/$lt/$lte/$ne/$eq/$in/$nin and $or/$and/$nor
- Boolean-based: compare true/false predicates; diff status codes, body length, specific content; use $regex for extraction
- Timing-based: use $where sleep payloads when server-side JavaScript is enabled; use heavy regex operations for ReDoS-style delays
- Error-based: provoke type errors, invalid operator errors, or JavaScript runtime exceptions; inspect verbose errors
</detection_channels>
<dbms_primitives>
<mongodb>
- Operators for injection: $ne, $gt, $lt, $gte, $lte, $in, $nin, $or, $and, $regex, $where, $exists, $type
- JavaScript execution: $where clause accepts JavaScript; $function in aggregations (MongoDB 4.4+)
- Version/info: db.version(), db.serverStatus(), db.hostInfo()
- Authentication bypass: {% raw %}{"username": {"$ne": ""}, "password": {"$ne": ""}}{% endraw %}
- Regex extraction: {% raw %}{"password": {"$regex": "^a.*"}}{% endraw %} iterate to extract full value
- $where JavaScript: {% raw %}{"$where": "this.username == 'admin' && this.password.match(/^a/)"}{% endraw %}
- Aggregation injection: $lookup to access other collections, $out to write results
</mongodb>
<couchdb>
- View injection via map/reduce functions (JavaScript execution)
- Mango queries: operator injection similar to MongoDB ($eq, $ne, $gt, $regex, etc.)
- _all_docs, _find endpoints with selector manipulation
- Design document manipulation for persistent code execution
</couchdb>
<redis>
- Command injection via protocol manipulation in poorly sanitized inputs
- Lua script injection: EVAL command with user-controlled scripts
- Key enumeration: KEYS *, SCAN with patterns
- Data exfiltration: GET, HGETALL, LRANGE, SMEMBERS
- Config manipulation: CONFIG SET to modify runtime behavior
- File write via RDB: CONFIG SET dir/dbfilename + SAVE (requires privileges)
</redis>
<cassandra>
- CQL injection: similar to SQL, string concatenation in WHERE clauses
- ALLOW FILTERING abuse for unauthorized data access
- UDF (User Defined Functions) if enabled: Java/JavaScript code execution
</cassandra>
<neo4j>
- Cypher injection: MATCH, WHERE, RETURN clause manipulation
- APOC procedures: apoc.load.json, apoc.cypher.run for extended capabilities
- Label/relationship injection to access unauthorized graph nodes
</neo4j>
</dbms_primitives>
<authentication_bypass>
<mongodb_operators>
- Basic bypass: {% raw %}{"username": "admin", "password": {"$ne": ""}}{% endraw %}
- Always true: {% raw %}{"username": {"$gt": ""}, "password": {"$gt": ""}}{% endraw %}
- Regex wildcard: {% raw %}{"username": "admin", "password": {"$regex": ".*"}}{% endraw %}
- $or injection: {% raw %}{"$or": [{"username": "admin"}, {"admin": true}], "password": {"$ne": ""}}{% endraw %}
- Type coercion: {% raw %}{"username": "admin", "password": {"$type": 2}}{% endraw %} (type 2 = string)
</mongodb_operators>
<query_string_injection>
- Array notation: ?username=admin&password[$ne]=wrongpass
- Nested operators: ?user[username]=admin&user[password][$gt]=
- URL-encoded JSON: ?filter=%7B%22username%22%3A%7B%22%24ne%22%3Anull%7D%7D
</query_string_injection>
</authentication_bypass>
<data_extraction>
<regex_extraction>
- Character-by-character: iterate {% raw %}{"field": {"$regex": "^<known>X"}}{% endraw %} for each position
- Binary search: use character ranges [a-m] vs [n-z] to reduce requests
- Case sensitivity: use $options: "i" for case-insensitive matching
- Special chars: escape regex metacharacters (. * + ? ^ $ { } [ ] \ | ( ))
</regex_extraction>
<boolean_extraction>
- Use $regex or $where to create true/false conditions
- Diff response length, status code, specific strings, or timing
- Extract field names via $exists: {% raw %}{"unknownField": {"$exists": true}}{% endraw %}
</boolean_extraction>
<javascript_extraction>
- $where with conditional: {% raw %}{"$where": "if(this.password[0]=='a'){sleep(5000)}"}{% endraw %}
- Access Object.keys(): {% raw %}{"$where": "Object.keys(this)[0][0]=='u'"}{% endraw %} to enumerate fields
- String operations: substring, charAt for positional extraction
</javascript_extraction>
<aggregation_based>
- $lookup to join with other collections and leak data
- $match with injected operators
- $project to select fields, $group to aggregate sensitive data
</aggregation_based>
</data_extraction>
<javascript_injection>
<where_clause>
- MongoDB $where executes JavaScript on the server; requires server-side JavaScript enabled (often disabled via --noscripting/security.javascriptEnabled)
- Basic: {% raw %}{"$where": "1==1"}{% endraw %} or {% raw %}{"$where": "true"}{% endraw %}
- Sleep for timing: {% raw %}{"$where": "sleep(5000) || true"}{% endraw %}
- Data access: {% raw %}{"$where": "this.password.length > 5"}{% endraw %}
- External calls (if allowed): {% raw %}{"$where": "this.constructor.constructor('return fetch(...)')()"}{% endraw %}
</where_clause>
<function_operator>
- MongoDB 4.4+ $function in aggregation: {% raw %}{"$function": {"body": "function(){...}", "args": [], "lang": "js"}}{% endraw %}
- Server-side JavaScript must be enabled (not disabled via --noscripting)
</function_operator>
<mapreduce>
- Inject into map/reduce functions if user input reaches these contexts
- CouchDB views: JavaScript in map functions
</mapreduce>
</javascript_injection>
<odm_and_framework_issues>
<mongoose>
- Dangerous patterns: find(req.body), findOne(req.query) without sanitization
- $where passthrough: user input reaching $where conditions
- Population/reference injection: manipulating $lookup-like operations
- Schema bypass: __proto__, constructor pollution via JSON parsing
</mongoose>
<morphia_java>
- String concatenation in filters instead of parameterized queries
- Criteria API misuse with raw strings
</morphia_java>
<pymongo>
- eval() usage with user input (deprecated but still dangerous)
- find() with unsanitized dictionaries from JSON input
- Codec manipulation affecting serialization
</pymongo>
</odm_and_framework_issues>
<waf_and_filter_bypasses>
<encoding_tricks>
- URL encoding: %24ne instead of $ne
- Double encoding: %2524ne
- Unicode normalization: using different Unicode representations
- JSON unicode escapes: \u0024ne for $ne
</encoding_tricks>
<operator_alternatives>
- $not instead of $ne: {% raw %}{"field": {"$not": {"$eq": "value"}}}{% endraw %}
- $nin instead of $ne: {% raw %}{"field": {"$nin": ["wrong"]}}{% endraw %}
- $expr with $eq/$ne in aggregation context
</operator_alternatives>
<structure_manipulation>
- Nested objects vs flat: {% raw %}{"a.b": "c"}{% endraw %} vs {% raw %}{"a": {"b": "c"}}{% endraw %}
- Array injection: ["$or", ...] in systems parsing arrays as operators
- Prototype pollution: __proto__, constructor.prototype in JSON
</structure_manipulation>
<comment_injection>
- MongoDB shell: // or /* */ in JavaScript contexts
- Newline injection in string concatenation scenarios
</comment_injection>
</waf_and_filter_bypasses>
<blind_extraction>
<binary_search>
- Use $regex with character ranges: ^[a-m] vs ^[n-z]
- Reduce character space: alphanumeric, then specific ranges
- Position tracking: ^known_prefix[a-m] for next character
</binary_search>
<timing_oracle>
- $where with conditional sleep: {% raw %}if(condition){sleep(N)}{% endraw %}
- ReDoS via pathological regex: ((a+)+)$ with long input
- Heavy operations: sorting large datasets conditionally
</timing_oracle>
<response_differential>
- Track: status codes (200/401/403/500), body length, specific strings, JSON structure
- Normalize responses (hash/length) to reduce noise
- Account for caching and rate limiting affecting responses
</response_differential>
</blind_extraction>
<server_side_injection>
<ssjs_mongodb>
- Server-Side JavaScript (SSJS) when $where, $function, mapReduce are exposed
- Potential for: DoS (infinite loops), data access, limited RCE depending on config
- Check: {% raw %}db.adminCommand({getParameter: 1, javascriptEnabled: 1}){% endraw %}
</ssjs_mongodb>
<dos_attacks>
- ReDoS: {% raw %}{"field": {"$regex": "^(a+)+$"}}{% endraw %} against long strings
- Resource exhaustion: large $in arrays, complex aggregations
- Infinite loops in $where if SSJS is enabled without timeouts
</dos_attacks>
</server_side_injection>
<graphql_nosql>
- Variables passed directly to MongoDB queries: {% raw %}query { user(filter: $input) }{% endraw %}
- Operator injection via GraphQL variables: {% raw %}{"filter": {"password": {"$ne": ""}}}{% endraw %}
- Batching attacks: multiple queries to enumerate data
- Introspection combined with injection for schema-aware attacks
</graphql_nosql>
<validation>
1. Demonstrate operator injection alters query behavior (auth bypass, extra data returned).
2. Show boolean/timing/error oracle confirms control over query predicates.
3. Extract verifiable data: version info, field names, partial sensitive values.
4. Provide minimal reproducible requests with clear injection points.
5. Document database type and version; defenses vary significantly across NoSQL systems.
</validation>
<false_positives>
- Strong typing/schema validation rejecting operator objects
- ODM sanitization stripping $ prefixes from keys
- Parameterized queries where operators cannot be injected
- WAF blocking all $ operators (verify with encoding bypasses first)
- Application logic unrelated to database predicates causing response variations
</false_positives>
<impact>
- Authentication and authorization bypass via manipulated query predicates
- Mass data exfiltration through regex extraction or aggregation manipulation
- Server-side JavaScript execution leading to DoS or limited RCE
- Privilege escalation by modifying user roles/permissions in database
- Denial of service via ReDoS or resource-intensive queries
</impact>
<pro_tips>
1. Start with $ne and $gt operators—they're most commonly injectable and easy to detect.
2. Use boolean oracles first; timing channels are noisier and slower.
3. For MongoDB, always test both JSON body and query string injection vectors.
4. $regex is powerful for extraction but escape special characters properly.
5. Check if SSJS is enabled before investing time in $where payloads.
6. Aggregation pipelines often have weaker validation than simple find() queries.
7. GraphQL + MongoDB is a common vulnerable combination; test variable injection.
8. Monitor for ReDoS potential—useful for both detection and responsible DoS impact assessment.
9. ODMs don't guarantee safety; audit raw query patterns and merge operations.
10. Different NoSQL databases have vastly different capabilities; tailor payloads to the target.
</pro_tips>
<remember>NoSQL injection succeeds where applications trust user input structure, not just values. Validate that input types match expectations, strip or reject query operators from user data, and use ODM features that enforce schemas. The absence of SQL syntax does not mean the absence of injection risk.</remember>
</nosql_injection_guide>
-163
View File
@@ -57,8 +57,6 @@ class Tracer:
self.agents: dict[str, dict[str, Any]] = {}
self.tool_executions: dict[int, dict[str, Any]] = {}
self.chat_messages: list[dict[str, Any]] = []
self.streaming_content: dict[str, str] = {}
self.interrupted_content: dict[str, str] = {}
self.vulnerability_reports: list[dict[str, Any]] = []
self.final_scan_result: str | None = None
@@ -447,33 +445,6 @@ class Tracer:
self.save_run_data(mark_complete=True)
posthog.end(self, exit_reason="finished_by_tool")
def log_agent_creation(
self,
agent_id: str,
name: str,
task: str,
parent_id: str | None = None,
) -> None:
agent_data: dict[str, Any] = {
"id": agent_id,
"name": name,
"task": task,
"status": "running",
"parent_id": parent_id,
"created_at": datetime.now(UTC).isoformat(),
"updated_at": datetime.now(UTC).isoformat(),
"tool_executions": [],
}
self.agents[agent_id] = agent_data
self._emit_event(
"agent.created",
actor={"agent_id": agent_id, "agent_name": name},
payload={"task": task, "parent_id": parent_id},
status="running",
source="strix.agents",
)
def log_chat_message(
self,
content: str,
@@ -503,110 +474,6 @@ class Tracer:
)
return message_id
def log_tool_execution_start(
self,
agent_id: str,
tool_name: str,
args: dict[str, Any],
) -> int:
execution_id = self._next_execution_id
self._next_execution_id += 1
now = datetime.now(UTC).isoformat()
execution_data = {
"execution_id": execution_id,
"agent_id": agent_id,
"tool_name": tool_name,
"args": args,
"status": "running",
"result": None,
"timestamp": now,
"started_at": now,
"completed_at": None,
}
self.tool_executions[execution_id] = execution_data
if agent_id in self.agents:
self.agents[agent_id]["tool_executions"].append(execution_id)
self._emit_event(
"tool.execution.started",
actor={
"agent_id": agent_id,
"tool_name": tool_name,
"execution_id": execution_id,
},
payload={"args": args},
status="running",
source="strix.tools",
)
return execution_id
def update_tool_execution(
self,
execution_id: int,
status: str,
result: Any | None = None,
) -> None:
if execution_id not in self.tool_executions:
return
tool_data = self.tool_executions[execution_id]
tool_data["status"] = status
tool_data["result"] = result
tool_data["completed_at"] = datetime.now(UTC).isoformat()
tool_name = str(tool_data.get("tool_name", "unknown"))
agent_id = str(tool_data.get("agent_id", "unknown"))
error_payload = result if status in {"error", "failed"} else None
self._emit_event(
"tool.execution.updated",
actor={
"agent_id": agent_id,
"tool_name": tool_name,
"execution_id": execution_id,
},
payload={"result": result},
status=status,
error=error_payload,
source="strix.tools",
)
if tool_name == "create_vulnerability_report":
finding_status = "reviewed" if status == "completed" else "rejected"
self._emit_event(
"finding.reviewed",
actor={"agent_id": agent_id, "tool_name": tool_name},
payload={"execution_id": execution_id, "result": result},
status=finding_status,
error=error_payload,
source="strix.findings",
)
def update_agent_status(
self,
agent_id: str,
status: str,
error_message: str | None = None,
) -> None:
if agent_id in self.agents:
self.agents[agent_id]["status"] = status
self.agents[agent_id]["updated_at"] = datetime.now(UTC).isoformat()
if error_message:
self.agents[agent_id]["error_message"] = error_message
self._emit_event(
"agent.status.updated",
actor={"agent_id": agent_id},
payload={"error_message": error_message},
status=status,
error=error_message,
source="strix.agents",
)
def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_metadata.update(
@@ -804,13 +671,6 @@ class Tracer:
pass
return 0.0
def get_agent_tools(self, agent_id: str) -> list[dict[str, Any]]:
return [
exec_data
for exec_data in list(self.tool_executions.values())
if exec_data.get("agent_id") == agent_id
]
def get_real_tool_count(self) -> int:
return sum(
1
@@ -879,28 +739,5 @@ class Tracer:
target["cost"] += cost
target["requests"] += requests
def update_streaming_content(self, agent_id: str, content: str) -> None:
self.streaming_content[agent_id] = content
def clear_streaming_content(self, agent_id: str) -> None:
self.streaming_content.pop(agent_id, None)
def get_streaming_content(self, agent_id: str) -> str | None:
return self.streaming_content.get(agent_id)
def finalize_streaming_as_interrupted(self, agent_id: str) -> str | None:
content = self.streaming_content.pop(agent_id, None)
if content and content.strip():
self.interrupted_content[agent_id] = content
self.log_chat_message(
content=content,
role="assistant",
agent_id=agent_id,
metadata={"interrupted": True},
)
return content
return self.interrupted_content.pop(agent_id, None)
def cleanup(self) -> None:
self.save_run_data(mark_complete=True)
-153
View File
@@ -1,153 +0,0 @@
"""Phase 1 smoke tests for StrixSession (memory compression wrapper)."""
from __future__ import annotations
from typing import Any
import pytest
from agents.memory.session import SessionABC
from strix.llm.strix_session import StrixSession
class _FakeUnderlying(SessionABC):
"""In-memory SessionABC used to drive StrixSession in tests."""
def __init__(self, items: list[Any] | None = None) -> None:
self.items: list[Any] = list(items or [])
self.session_id = "fake-session"
async def get_items(self, limit: int | None = None) -> list[Any]:
if limit is None:
return list(self.items)
return list(self.items[-limit:])
async def add_items(self, items: list[Any]) -> None:
self.items.extend(items)
async def pop_item(self) -> Any | None:
return self.items.pop() if self.items else None
async def clear_session(self) -> None:
self.items.clear()
class _CompressorOK:
"""Compressor stand-in that compresses by keeping the last item."""
def __init__(self) -> None:
self.calls = 0
def compress_history(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
self.calls += 1
return messages[-1:] if len(messages) > 1 else messages
class _CompressorBoom:
"""Compressor stand-in that always raises."""
def __init__(self) -> None:
self.calls = 0
def compress_history(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
self.calls += 1
raise RuntimeError("compressor offline")
@pytest.fixture
def items() -> list[dict[str, Any]]:
return [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "second"},
{"role": "user", "content": "third"},
]
@pytest.mark.asyncio
async def test_get_items_compresses_when_compressor_ok(items: list[dict[str, Any]]) -> None:
underlying = _FakeUnderlying(items)
session = StrixSession(underlying, compressor=_CompressorOK())
out = await session.get_items()
assert len(out) == 1
assert out[0]["content"] == "third"
@pytest.mark.asyncio
async def test_get_items_returns_empty_without_calling_compressor() -> None:
"""If underlying has no items, don't even invoke the compressor."""
underlying = _FakeUnderlying([])
compressor = _CompressorOK()
session = StrixSession(underlying, compressor=compressor)
out = await session.get_items()
assert out == []
assert compressor.calls == 0
@pytest.mark.asyncio
async def test_get_items_falls_back_to_uncompressed_on_exception(
items: list[dict[str, Any]],
) -> None:
"""C10 (AUDIT_R2): compressor failure must not tear down the run."""
underlying = _FakeUnderlying(items)
compressor = _CompressorBoom()
session = StrixSession(underlying, compressor=compressor)
out = await session.get_items()
# Uncompressed history returned.
assert out == items
# Flag set so subsequent calls skip the compressor.
assert session.compression_disabled is True
@pytest.mark.asyncio
async def test_compressor_disabled_after_first_failure(items: list[dict[str, Any]]) -> None:
"""Round 3.4 §E2 / W5 — once the compressor fails, skip it forever."""
underlying = _FakeUnderlying(items)
compressor = _CompressorBoom()
session = StrixSession(underlying, compressor=compressor)
# First call: compressor invoked, raises, flag set.
await session.get_items()
assert compressor.calls == 1
assert session.compression_disabled is True
# Second + third call: compressor short-circuited.
await session.get_items()
await session.get_items()
assert compressor.calls == 1
@pytest.mark.asyncio
async def test_writes_pass_through(items: list[dict[str, Any]]) -> None:
underlying = _FakeUnderlying()
session = StrixSession(underlying, compressor=_CompressorOK())
await session.add_items(items)
assert underlying.items == items
popped = await session.pop_item()
assert popped == items[-1]
await session.clear_session()
assert underlying.items == []
@pytest.mark.asyncio
async def test_session_id_passes_through() -> None:
underlying = _FakeUnderlying()
session = StrixSession(underlying, compressor=_CompressorOK())
assert session.session_id == "fake-session"
@pytest.mark.asyncio
async def test_get_items_respects_limit(items: list[dict[str, Any]]) -> None:
"""``limit`` is forwarded to the underlying session before compression."""
underlying = _FakeUnderlying(items)
session = StrixSession(underlying, compressor=_CompressorOK())
out = await session.get_items(limit=2)
# Underlying returned last 2 items; compressor kept the last 1.
assert len(out) == 1
assert out[0]["content"] == "third"
+11 -19
View File
@@ -39,21 +39,15 @@ def test_tracer_local_mode_writes_jsonl_with_correlation(
tracer = Tracer("local-observability")
set_global_tracer(tracer)
tracer.set_scan_config({"targets": ["https://example.com"], "user_instructions": "focus auth"})
tracer.log_agent_creation("agent-1", "Root Agent", "scan auth")
tracer.log_chat_message("starting scan", "user", "agent-1")
execution_id = tracer.log_tool_execution_start(
"agent-1",
"send_request",
{"url": "https://example.com/login"},
)
tracer.update_tool_execution(execution_id, "completed", {"status_code": 200, "body": "ok"})
tracer.log_chat_message("scanning login form", "assistant", "agent-1")
events_path = tmp_path / "strix_runs" / "local-observability" / "events.jsonl"
assert events_path.exists()
events = _load_events(events_path)
assert any(event["event_type"] == "tool.execution.updated" for event in events)
assert not any(event["event_type"] == "traffic.intercepted" for event in events)
assert any(event["event_type"] == "chat.message" for event in events)
assert any(event["event_type"] == "run.configured" for event in events)
for event in events:
assert event["run_id"] == "local-observability"
@@ -66,20 +60,15 @@ def test_tracer_redacts_sensitive_payloads(monkeypatch: pytest.MonkeyPatch, tmp_
tracer = Tracer("redaction-run")
set_global_tracer(tracer)
execution_id = tracer.log_tool_execution_start(
tracer.log_chat_message(
"request failed with token sk-secret-token-value",
"assistant",
"agent-1",
"send_request",
{
"url": "https://example.com",
metadata={
"api_key": "sk-secret-token-value",
"authorization": "Bearer super-secret-token",
},
)
tracer.update_tool_execution(
execution_id,
"error",
{"error": "request failed with token sk-secret-token-value"},
)
events_path = tmp_path / "strix_runs" / "redaction-run" / "events.jsonl"
events = _load_events(events_path)
@@ -255,7 +244,10 @@ def test_events_with_agent_id_include_agent_name(
tracer = Tracer("agent-name-enrichment")
set_global_tracer(tracer)
tracer.log_agent_creation("agent-1", "Root Agent", "scan auth")
# _enrich_actor pulls names from the tracer's agents dict; populate it
# the same way the orchestration path will once wired (placeholder
# until live wiring lands).
tracer.agents["agent-1"] = {"name": "Root Agent"}
tracer.log_chat_message("hello", "assistant", "agent-1")
events_path = tmp_path / "strix_runs" / "agent-name-enrichment" / "events.jsonl"