omnimancer-cli 0.2.13__tar.gz → 0.2.19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {omnimancer_cli-0.2.13/omnimancer_cli.egg-info → omnimancer_cli-0.2.19}/PKG-INFO +1 -1
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/headless.py +21 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/tool_handler.py +12 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/file_system_manager.py +49 -4
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/program_executor.py +9 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent_managers.py +24 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/models.py +23 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/permission_controller.py +16 -1
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/security_manager.py +11 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/azure.py +7 -3
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/mistral.py +7 -3
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/openai.py +6 -7
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/openrouter.py +11 -4
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/xai.py +7 -3
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19/omnimancer_cli.egg-info}/PKG-INFO +1 -1
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/pyproject.toml +1 -1
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_models.py +114 -65
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_openrouter_provider.py +97 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_tool_handler.py +15 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_xai_provider.py +5 -3
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/LICENSE +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/README.md +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/__main__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/agent_loop.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_formatter.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_integration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/approval_prompt.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/command_dispatch.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/commands.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/completion.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/display.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/dynamic_commands.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/handlers/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/interface.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/subagent.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/cli/system_prompts.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_context.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_interface.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/approval_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/config.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/read_before_write_errors.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/status_core.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/status_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/tool_definitions.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/types.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/web_client.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent_engine.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent_mode_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/chat_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/config_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/config_migration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/config_validator.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/conversation_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/engine.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/env_loader.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/fallback_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/health_monitor.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/history_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/hooks.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/provider_capabilities.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/provider_initializer.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/provider_registry.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/rate_limit_fallback.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/approval_workflow.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/audit_logger.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/permission_rules.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/sandbox_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/signal_handler.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/mcp/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/mcp/client.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/mcp/manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/base.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/bedrock.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/claude.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/claude_code.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/claude_credentials.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/cohere.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/digitalocean.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/factory.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/gemini.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/ollama.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/perplexity.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/providers/vertex.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/ui/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/ui/cancellation_handler.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/ui/progress_indicator.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/ui/streaming_display.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/utils/__init__.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/utils/error_handler.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/utils/errors.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/utils/retry.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/SOURCES.txt +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/entry_points.txt +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/requires.txt +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer_cli.egg-info/top_level.txt +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/setup.cfg +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_approval_integration_compatibility.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_approval_preview_format.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_approval_remember.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_approval_system.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_azure_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_bedrock_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_claude_code_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_claude_credentials.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_claude_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_cohere_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_command_approval_security.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_config_comprehensive.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_conversation_history.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_conversation_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_core_engine.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_digitalocean_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_enhanced_model_info.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_env_loader.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_error_handling_robustness.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_execution_timeout_errors.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_fallback_manager.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_fixtures.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_gemini_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_hooks.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_httpx_compatibility.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_import_validation.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_interface_smoke_tests.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_mcp_integration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_mcp_integration_layer.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_mcp_management_commands.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_mistral_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_ollama_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_openai_context_fit.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_openai_provider_tools.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_permission_rules.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_perplexity_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_program_executor.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_provider_base.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_provider_capability_consistency.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_provider_discovery.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_provider_integration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_provider_registry.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_rate_limit_fallback.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_retry.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_security_validation.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_system_integration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_system_prompts.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_tool_calling_integration.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_vertex_provider.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_web_request_markers.py +0 -0
- {omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/tests/test_workflows_comprehensive.py +0 -0
|
@@ -255,6 +255,26 @@ class HeadlessRunner:
|
|
|
255
255
|
if enhanced is not None:
|
|
256
256
|
enhanced.set_approval_callback(_approve)
|
|
257
257
|
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _enable_full_trust(agent_engine: Any) -> None:
|
|
260
|
+
"""Relax the hard security layers for unattended full-trust runs.
|
|
261
|
+
|
|
262
|
+
Auto-approval alone is not enough: the command argument sanitizer
|
|
263
|
+
(pipes, &&, redirects), the forbidden-command list, the sensitive
|
|
264
|
+
filename patterns (*key*, *token*, .env, ...), and the 30s default
|
|
265
|
+
command timeout all block operations before/outside the approval
|
|
266
|
+
step, so a --dangerously-skip-permissions run still could not do real
|
|
267
|
+
work. The caller (e.g. a CI or agent container) is the security
|
|
268
|
+
boundary; hard-restricted system paths (~/.ssh, /etc, ...) stay
|
|
269
|
+
blocked.
|
|
270
|
+
"""
|
|
271
|
+
executor = getattr(agent_engine, "executor", None)
|
|
272
|
+
if executor is not None and hasattr(executor, "set_full_trust"):
|
|
273
|
+
executor.set_full_trust(True)
|
|
274
|
+
file_system = getattr(agent_engine, "file_system", None)
|
|
275
|
+
if file_system is not None and hasattr(file_system, "set_full_trust"):
|
|
276
|
+
file_system.set_full_trust(True)
|
|
277
|
+
|
|
258
278
|
async def run(self, prompt: str) -> int:
|
|
259
279
|
agent_engine = getattr(self._engine, "agent_engine", None)
|
|
260
280
|
if not agent_engine:
|
|
@@ -263,6 +283,7 @@ class HeadlessRunner:
|
|
|
263
283
|
|
|
264
284
|
if self._no_approval:
|
|
265
285
|
self._enable_auto_approval(agent_engine)
|
|
286
|
+
self._enable_full_trust(agent_engine)
|
|
266
287
|
|
|
267
288
|
model = self._engine.config_manager.get_config().default_provider or "unknown"
|
|
268
289
|
self._emitter.emit_init(model)
|
|
@@ -150,6 +150,18 @@ class ToolHandler:
|
|
|
150
150
|
return list(CODING_AGENT_TOOLS)
|
|
151
151
|
|
|
152
152
|
async def execute_tool_call(self, tool_call: ToolCall) -> ToolResult:
|
|
153
|
+
# ToolCall normalizes JSON-string arguments on construction; anything
|
|
154
|
+
# still not a dict here is malformed — tell the model instead of
|
|
155
|
+
# crashing on args.get(...) inside a handler.
|
|
156
|
+
if not isinstance(tool_call.arguments, dict):
|
|
157
|
+
return ToolResult(
|
|
158
|
+
content="",
|
|
159
|
+
error=(
|
|
160
|
+
f"Malformed arguments for {tool_call.name}: expected a "
|
|
161
|
+
f"JSON object, got: {str(tool_call.arguments)[:200]}. "
|
|
162
|
+
"Re-issue the call with the parameters as a JSON object."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
153
165
|
try:
|
|
154
166
|
# Read and Edit need post-processing / multi-step logic.
|
|
155
167
|
if tool_call.name in _EDIT_TOOLS:
|
{omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/agent/file_system_manager.py
RENAMED
|
@@ -79,6 +79,14 @@ class FileSystemManager:
|
|
|
79
79
|
# Operation tracking
|
|
80
80
|
self.active_operations: Dict[str, Dict[str, Any]] = {}
|
|
81
81
|
|
|
82
|
+
def set_full_trust(self, enabled: bool) -> None:
|
|
83
|
+
"""Enable/disable full-trust mode on the underlying security manager.
|
|
84
|
+
|
|
85
|
+
In full trust, sensitive name patterns (.env, *key*, *token*, ...)
|
|
86
|
+
stop blocking reads/writes; hard-restricted system paths still deny.
|
|
87
|
+
"""
|
|
88
|
+
self.security.set_full_trust(enabled)
|
|
89
|
+
|
|
82
90
|
async def _request_approval_if_needed(self, operation: Operation) -> bool:
|
|
83
91
|
"""Request approval for operation if approval manager is configured."""
|
|
84
92
|
if not self.require_approval or not self.approval_manager:
|
|
@@ -242,14 +250,51 @@ class FileSystemManager:
|
|
|
242
250
|
"""
|
|
243
251
|
Simple boolean check for file existence.
|
|
244
252
|
|
|
253
|
+
Performs a lightweight path-validation check followed by an async
|
|
254
|
+
filesystem probe. Unlike :meth:`check_file_exists`, this method does
|
|
255
|
+
*not* invoke the full security-audit pipeline (permission logging, risk
|
|
256
|
+
assessment, approval workflow). It is intended for high-frequency
|
|
257
|
+
callers (e.g. batch operations) that need a fast boolean answer.
|
|
258
|
+
|
|
259
|
+
.. warning::
|
|
260
|
+
This method enforces path-level access controls — hard-restricted
|
|
261
|
+
system paths (``/etc``, ``~/.ssh``, cloud-credential stores, etc.)
|
|
262
|
+
and sensitive-name patterns are still blocked via
|
|
263
|
+
:meth:`~omnimancer.core.security.permission_controller.PermissionController.validate_path_access`.
|
|
264
|
+
However, it skips the per-operation audit-log entries produced by
|
|
265
|
+
:meth:`~omnimancer.core.security.SecurityManager.secure_file_access`.
|
|
266
|
+
Do not use this method where per-access audit trails are required.
|
|
267
|
+
|
|
245
268
|
Args:
|
|
246
|
-
path: Path to check for existence
|
|
269
|
+
path: Path to check for existence.
|
|
247
270
|
|
|
248
271
|
Returns:
|
|
249
|
-
True if
|
|
272
|
+
True if the path passes security validation and exists on the
|
|
273
|
+
filesystem; False if the path is security-denied or does not exist.
|
|
250
274
|
"""
|
|
251
|
-
|
|
252
|
-
|
|
275
|
+
try:
|
|
276
|
+
resolved = Path(path).resolve()
|
|
277
|
+
except (OSError, ValueError) as e:
|
|
278
|
+
logger.warning("file_exists: invalid path %r: %s", path, e)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
# Validate path against security policy without invoking the expensive
|
|
282
|
+
# audit pipeline. This blocks hard-restricted system paths and
|
|
283
|
+
# sensitive-name patterns (e.g. /etc, ~/.ssh, *.db) the same way the
|
|
284
|
+
# full pipeline would.
|
|
285
|
+
if not self.security.permissions.validate_path_access(str(resolved), "read"):
|
|
286
|
+
logger.warning(
|
|
287
|
+
"file_exists: access denied by security policy for %s", resolved
|
|
288
|
+
)
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
exists = bool(await aiofiles.os.path.exists(resolved))
|
|
293
|
+
logger.debug("file_exists: %s → %s", resolved, exists)
|
|
294
|
+
return exists
|
|
295
|
+
except OSError as e:
|
|
296
|
+
logger.warning("file_exists: OS error checking %s: %s", resolved, e)
|
|
297
|
+
return False
|
|
253
298
|
|
|
254
299
|
async def write_file_with_confirmation(
|
|
255
300
|
self,
|
|
@@ -86,6 +86,10 @@ class CommandValidator:
|
|
|
86
86
|
"""Validates commands against security policies."""
|
|
87
87
|
|
|
88
88
|
def __init__(self) -> None:
|
|
89
|
+
# Full-trust mode (unattended headless runs where an outer sandbox is
|
|
90
|
+
# the security boundary): argument sanitization is skipped so shell
|
|
91
|
+
# constructs (pipes, &&, redirects) reach the shell executor intact.
|
|
92
|
+
self.full_trust = False
|
|
89
93
|
self.command_categories = {
|
|
90
94
|
CommandCategory.SAFE_READ: {
|
|
91
95
|
"ls",
|
|
@@ -295,6 +299,11 @@ class CommandValidator:
|
|
|
295
299
|
|
|
296
300
|
def validate_command_args(self, command: str, args: List[str]) -> List[str]:
|
|
297
301
|
"""Validate and sanitize command arguments."""
|
|
302
|
+
# Full trust: commands run via the shell on purpose — pipes, &&, and
|
|
303
|
+
# redirects are legitimate agent usage there.
|
|
304
|
+
if self.full_trust:
|
|
305
|
+
return list(args)
|
|
306
|
+
|
|
298
307
|
import re
|
|
299
308
|
|
|
300
309
|
# Define dangerous shell metacharacters and patterns
|
|
@@ -159,6 +159,10 @@ class ProgramExecutor(BaseManager):
|
|
|
159
159
|
}
|
|
160
160
|
self.timeout_seconds = self.default_config.timeout_seconds
|
|
161
161
|
|
|
162
|
+
# Full-trust mode (unattended headless runs where an outer sandbox is
|
|
163
|
+
# the security boundary) — see set_full_trust().
|
|
164
|
+
self.full_trust = False
|
|
165
|
+
|
|
162
166
|
async def execute_operation(self, operation: Operation) -> OperationResult:
|
|
163
167
|
"""Execute command operation using enhanced executor.
|
|
164
168
|
|
|
@@ -261,6 +265,22 @@ class ProgramExecutor(BaseManager):
|
|
|
261
265
|
"""Terminate a running command."""
|
|
262
266
|
return await self.enhanced_executor.terminate_command(execution_id)
|
|
263
267
|
|
|
268
|
+
def set_full_trust(self, enabled: bool) -> None:
|
|
269
|
+
"""Enable full-trust mode for unattended runs.
|
|
270
|
+
|
|
271
|
+
The caller (e.g. a headless agent inside a container) is the security
|
|
272
|
+
boundary: the forbidden-command list and argument sanitization stop
|
|
273
|
+
blocking, and the default per-command timeout is raised so real build/
|
|
274
|
+
test runs can finish. Explicit per-operation timeouts are still honored.
|
|
275
|
+
"""
|
|
276
|
+
self.full_trust = enabled
|
|
277
|
+
if enabled:
|
|
278
|
+
self.timeout_seconds = 600
|
|
279
|
+
self.default_config.timeout_seconds = 600
|
|
280
|
+
validator = getattr(self.enhanced_executor, "validator", None)
|
|
281
|
+
if validator is not None:
|
|
282
|
+
validator.full_trust = enabled
|
|
283
|
+
|
|
264
284
|
def _validate_command(self, command: str) -> bool:
|
|
265
285
|
"""
|
|
266
286
|
Validate command for backward compatibility with tests.
|
|
@@ -278,6 +298,10 @@ class ProgramExecutor(BaseManager):
|
|
|
278
298
|
Security is provided through the approval system's risk assessment
|
|
279
299
|
and user confirmation. Only explicitly dangerous commands are blocked.
|
|
280
300
|
"""
|
|
301
|
+
# Full trust: forbidden-command blocking is disabled.
|
|
302
|
+
if self.full_trust:
|
|
303
|
+
return True
|
|
304
|
+
|
|
281
305
|
# Extract the base command (first word)
|
|
282
306
|
base_command = command.strip().split()[0] if command.strip() else ""
|
|
283
307
|
|
|
@@ -113,6 +113,29 @@ class ToolCall:
|
|
|
113
113
|
# Provider-assigned call id (OpenAI protocol pairs results to calls by id).
|
|
114
114
|
id: Optional[str] = None
|
|
115
115
|
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
"""Normalize arguments that arrive as a JSON-encoded string.
|
|
118
|
+
|
|
119
|
+
OpenAI-protocol servers send ``function.arguments`` as a JSON string,
|
|
120
|
+
and some models double-encode it. A value that doesn't decode to an
|
|
121
|
+
object is left as-is so the tool handler can report it to the model.
|
|
122
|
+
"""
|
|
123
|
+
import json
|
|
124
|
+
|
|
125
|
+
if self.arguments is None:
|
|
126
|
+
self.arguments = {}
|
|
127
|
+
return
|
|
128
|
+
decoded: Any = self.arguments
|
|
129
|
+
for _ in range(2):
|
|
130
|
+
if not isinstance(decoded, str):
|
|
131
|
+
break
|
|
132
|
+
try:
|
|
133
|
+
decoded = json.loads(decoded)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return
|
|
136
|
+
if isinstance(decoded, dict):
|
|
137
|
+
self.arguments = decoded
|
|
138
|
+
|
|
116
139
|
|
|
117
140
|
@dataclass
|
|
118
141
|
class ToolResultRecord:
|
{omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/permission_controller.py
RENAMED
|
@@ -46,6 +46,11 @@ class PermissionController:
|
|
|
46
46
|
self.allowed_commands = self._get_default_allowed_commands()
|
|
47
47
|
self.permission_rules = self._get_default_permission_rules()
|
|
48
48
|
|
|
49
|
+
# Full-trust mode (unattended headless runs where an outer sandbox is
|
|
50
|
+
# the security boundary): sensitive name patterns and the command
|
|
51
|
+
# allowlist stop blocking. Hard-restricted paths still deny.
|
|
52
|
+
self.full_trust = False
|
|
53
|
+
|
|
49
54
|
# Initialize approval memory storage
|
|
50
55
|
self._approval_memory: Dict[str, Dict[str, Any]] = {}
|
|
51
56
|
|
|
@@ -193,11 +198,16 @@ class PermissionController:
|
|
|
193
198
|
# Normalize path
|
|
194
199
|
normalized_path = str(Path(path).resolve())
|
|
195
200
|
|
|
196
|
-
# Hard-restricted paths are denied regardless of approval
|
|
201
|
+
# Hard-restricted paths are denied regardless of approval —
|
|
202
|
+
# including full-trust mode.
|
|
197
203
|
for restricted in self.restricted_paths:
|
|
198
204
|
if self._path_matches_pattern(normalized_path, restricted):
|
|
199
205
|
return False
|
|
200
206
|
|
|
207
|
+
# Full trust treats every operation as explicitly allowed.
|
|
208
|
+
if self.full_trust:
|
|
209
|
+
allow_sensitive = True
|
|
210
|
+
|
|
201
211
|
# Sensitive name patterns are denied unless explicitly allowed.
|
|
202
212
|
if not allow_sensitive:
|
|
203
213
|
for pattern in self.sensitive_patterns:
|
|
@@ -238,6 +248,11 @@ class PermissionController:
|
|
|
238
248
|
|
|
239
249
|
def validate_command(self, command: str) -> bool:
|
|
240
250
|
"""Validate if command execution is allowed."""
|
|
251
|
+
# Full trust: the allowlist and pattern checks stop blocking — the
|
|
252
|
+
# outer sandbox is the security boundary.
|
|
253
|
+
if self.full_trust:
|
|
254
|
+
return True
|
|
255
|
+
|
|
241
256
|
# Extract base command (first word)
|
|
242
257
|
base_command = command.strip().split()[0]
|
|
243
258
|
|
{omnimancer_cli-0.2.13 → omnimancer_cli-0.2.19}/omnimancer/core/security/security_manager.py
RENAMED
|
@@ -327,6 +327,17 @@ class SecurityManager:
|
|
|
327
327
|
result["stderr"] = f"Security manager error: {str(e)}"
|
|
328
328
|
return result
|
|
329
329
|
|
|
330
|
+
def set_full_trust(self, enabled: bool) -> None:
|
|
331
|
+
"""Enable/disable full-trust mode on the permission controller.
|
|
332
|
+
|
|
333
|
+
Full trust is for unattended runs (e.g. headless agents inside a
|
|
334
|
+
container) where the caller is the security boundary: sensitive name
|
|
335
|
+
patterns and the command allowlist stop blocking operations. The
|
|
336
|
+
hard-restricted system paths in PermissionController.restricted_paths
|
|
337
|
+
still deny.
|
|
338
|
+
"""
|
|
339
|
+
self.permissions.full_trust = enabled
|
|
340
|
+
|
|
330
341
|
async def secure_file_access(
|
|
331
342
|
self,
|
|
332
343
|
file_path: str,
|
|
@@ -363,19 +363,23 @@ class AzureProvider(BaseProvider):
|
|
|
363
363
|
|
|
364
364
|
if choices and len(choices) > 0:
|
|
365
365
|
message = choices[0].get("message", {})
|
|
366
|
-
|
|
366
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
367
|
+
content = message.get("content") or ""
|
|
367
368
|
usage = data.get("usage", {})
|
|
368
369
|
|
|
369
|
-
# Extract tool calls if present
|
|
370
|
+
# Extract tool calls if present. The wire protocol sends
|
|
371
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
372
|
+
# it to a dict on construction.
|
|
370
373
|
tool_calls = []
|
|
371
374
|
if "tool_calls" in message:
|
|
372
|
-
for tool_call in message["tool_calls"]:
|
|
375
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
373
376
|
if tool_call.get("type") == "function":
|
|
374
377
|
function = tool_call.get("function", {})
|
|
375
378
|
tool_calls.append(
|
|
376
379
|
ToolCall(
|
|
377
380
|
name=function.get("name", ""),
|
|
378
381
|
arguments=function.get("arguments", {}),
|
|
382
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
379
383
|
)
|
|
380
384
|
)
|
|
381
385
|
|
|
@@ -324,19 +324,23 @@ class MistralProvider(BaseProvider):
|
|
|
324
324
|
|
|
325
325
|
if choices and len(choices) > 0:
|
|
326
326
|
message = choices[0].get("message", {})
|
|
327
|
-
|
|
327
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
328
|
+
content = message.get("content") or ""
|
|
328
329
|
usage = data.get("usage", {})
|
|
329
330
|
|
|
330
|
-
# Extract tool calls if present
|
|
331
|
+
# Extract tool calls if present. The wire protocol sends
|
|
332
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
333
|
+
# it to a dict on construction.
|
|
331
334
|
tool_calls = []
|
|
332
335
|
if "tool_calls" in message:
|
|
333
|
-
for tool_call in message["tool_calls"]:
|
|
336
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
334
337
|
if tool_call.get("type") == "function":
|
|
335
338
|
function = tool_call.get("function", {})
|
|
336
339
|
tool_calls.append(
|
|
337
340
|
ToolCall(
|
|
338
341
|
name=function.get("name", ""),
|
|
339
342
|
arguments=function.get("arguments", {}),
|
|
343
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
340
344
|
)
|
|
341
345
|
)
|
|
342
346
|
|
|
@@ -332,7 +332,8 @@ class OpenAIProvider(BaseProvider):
|
|
|
332
332
|
|
|
333
333
|
if choices and len(choices) > 0:
|
|
334
334
|
message = choices[0].get("message", {})
|
|
335
|
-
|
|
335
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
336
|
+
content = message.get("content") or ""
|
|
336
337
|
usage = data.get("usage", {})
|
|
337
338
|
|
|
338
339
|
return ChatResponse(
|
|
@@ -462,18 +463,16 @@ class OpenAIProvider(BaseProvider):
|
|
|
462
463
|
tool_calls = None
|
|
463
464
|
raw_tool_calls = msg.get("tool_calls")
|
|
464
465
|
if raw_tool_calls:
|
|
465
|
-
import json
|
|
466
|
-
|
|
467
466
|
tool_calls = []
|
|
468
467
|
for i, tc in enumerate(raw_tool_calls):
|
|
469
468
|
func = tc.get("function", {})
|
|
470
|
-
args = func.get("arguments", "{}")
|
|
471
|
-
if isinstance(args, str):
|
|
472
|
-
args = json.loads(args)
|
|
473
469
|
tool_calls.append(
|
|
474
470
|
ToolCall(
|
|
471
|
+
# ToolCall normalizes JSON-string arguments
|
|
472
|
+
# (including double-encoded ones) on
|
|
473
|
+
# construction.
|
|
475
474
|
name=func.get("name", ""),
|
|
476
|
-
arguments=
|
|
475
|
+
arguments=func.get("arguments", {}),
|
|
477
476
|
# Some OpenAI-compatible servers omit ids;
|
|
478
477
|
# synthesize one so results can pair to calls.
|
|
479
478
|
id=tc.get("id") or f"call_{i}",
|
|
@@ -377,7 +377,9 @@ class OpenRouterProvider(BaseProvider):
|
|
|
377
377
|
|
|
378
378
|
if choices and len(choices) > 0:
|
|
379
379
|
message = choices[0].get("message", {})
|
|
380
|
-
|
|
380
|
+
# Tool-call responses carry "content": null — coalesce to ""
|
|
381
|
+
# so the fallback-warning concatenation below can't TypeError.
|
|
382
|
+
content = message.get("content") or ""
|
|
381
383
|
usage = data.get("usage", {})
|
|
382
384
|
|
|
383
385
|
# Extract model used (OpenRouter may route to different models)
|
|
@@ -446,19 +448,24 @@ class OpenRouterProvider(BaseProvider):
|
|
|
446
448
|
|
|
447
449
|
if choices and len(choices) > 0:
|
|
448
450
|
message = choices[0].get("message", {})
|
|
449
|
-
|
|
451
|
+
# Tool-call responses carry "content": null — coalesce to ""
|
|
452
|
+
# so the fallback-warning concatenation below can't TypeError.
|
|
453
|
+
content = message.get("content") or ""
|
|
450
454
|
usage = data.get("usage", {})
|
|
451
455
|
|
|
452
|
-
# Extract tool calls if present
|
|
456
|
+
# Extract tool calls if present. The wire protocol sends
|
|
457
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
458
|
+
# it to a dict on construction.
|
|
453
459
|
tool_calls = []
|
|
454
460
|
if "tool_calls" in message:
|
|
455
|
-
for tool_call in message["tool_calls"]:
|
|
461
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
456
462
|
if tool_call.get("type") == "function":
|
|
457
463
|
function = tool_call.get("function", {})
|
|
458
464
|
tool_calls.append(
|
|
459
465
|
ToolCall(
|
|
460
466
|
name=function.get("name", ""),
|
|
461
467
|
arguments=function.get("arguments", {}),
|
|
468
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
462
469
|
)
|
|
463
470
|
)
|
|
464
471
|
|
|
@@ -327,19 +327,23 @@ class XAIProvider(BaseProvider):
|
|
|
327
327
|
|
|
328
328
|
if choices and len(choices) > 0:
|
|
329
329
|
message = choices[0].get("message", {})
|
|
330
|
-
|
|
330
|
+
# Tool-call responses carry "content": null — coalesce to "".
|
|
331
|
+
content = message.get("content") or ""
|
|
331
332
|
usage = data.get("usage", {})
|
|
332
333
|
|
|
333
|
-
# Extract tool calls if present
|
|
334
|
+
# Extract tool calls if present. The wire protocol sends
|
|
335
|
+
# function.arguments as a JSON string; ToolCall normalizes
|
|
336
|
+
# it to a dict on construction.
|
|
334
337
|
tool_calls = []
|
|
335
338
|
if "tool_calls" in message:
|
|
336
|
-
for tool_call in message["tool_calls"]:
|
|
339
|
+
for i, tool_call in enumerate(message["tool_calls"]):
|
|
337
340
|
if tool_call.get("type") == "function":
|
|
338
341
|
function = tool_call.get("function", {})
|
|
339
342
|
tool_calls.append(
|
|
340
343
|
ToolCall(
|
|
341
344
|
name=function.get("name", ""),
|
|
342
345
|
arguments=function.get("arguments", {}),
|
|
346
|
+
id=tool_call.get("id") or f"call_{i}",
|
|
343
347
|
)
|
|
344
348
|
)
|
|
345
349
|
|
|
@@ -591,68 +591,117 @@ class TestStreamEvent:
|
|
|
591
591
|
|
|
592
592
|
event = StreamEvent(type=StreamEventType.ERROR, error="Connection lost")
|
|
593
593
|
assert event.error == "Connection lost"
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
class TestParseDescribedToolCalls:
|
|
597
|
-
"""parse_described_tool_calls recovers calls a model emitted as text.
|
|
598
|
-
|
|
599
|
-
Weak models mimic the "[Called tools: ...]" history notation instead of
|
|
600
|
-
issuing native tool calls; the parser turns that text back into ToolCall
|
|
601
|
-
objects so the agent loop can keep going.
|
|
602
|
-
"""
|
|
603
|
-
|
|
604
|
-
def test_round_trips_describe_output(self):
|
|
605
|
-
from omnimancer.core.models import (
|
|
606
|
-
ToolCall,
|
|
607
|
-
describe_tool_calls,
|
|
608
|
-
parse_described_tool_calls,
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
calls = [
|
|
612
|
-
ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
|
|
613
|
-
ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
|
|
614
|
-
]
|
|
615
|
-
parsed = parse_described_tool_calls(describe_tool_calls(calls))
|
|
616
|
-
|
|
617
|
-
assert [(c.name, c.arguments) for c in parsed] == [
|
|
618
|
-
(c.name, c.arguments) for c in calls
|
|
619
|
-
]
|
|
620
|
-
|
|
621
|
-
def test_plain_text_returns_empty(self):
|
|
622
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
623
|
-
|
|
624
|
-
assert parse_described_tool_calls("The auth flow looks correct.") == []
|
|
625
|
-
assert parse_described_tool_calls("") == []
|
|
626
|
-
assert parse_described_tool_calls(None) == []
|
|
627
|
-
|
|
628
|
-
def test_nested_json_with_braces_and_commas(self):
|
|
629
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
630
|
-
|
|
631
|
-
text = (
|
|
632
|
-
'[Called tools: Grep({"glob": "*.{ts,tsx}", '
|
|
633
|
-
'"output_mode": "files_with_matches", '
|
|
634
|
-
'"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
|
|
635
|
-
)
|
|
636
|
-
parsed = parse_described_tool_calls(text)
|
|
637
|
-
|
|
638
|
-
assert len(parsed) == 1
|
|
639
|
-
assert parsed[0].name == "Grep"
|
|
640
|
-
assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
|
|
641
|
-
|
|
642
|
-
def test_marker_embedded_in_other_text(self):
|
|
643
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
644
|
-
|
|
645
|
-
text = (
|
|
646
|
-
"Let me search for that.\n"
|
|
647
|
-
'[Called tools: Bash({"command": "git log --oneline -5"})]'
|
|
648
|
-
)
|
|
649
|
-
parsed = parse_described_tool_calls(text)
|
|
650
|
-
|
|
651
|
-
assert len(parsed) == 1
|
|
652
|
-
assert parsed[0].name == "Bash"
|
|
653
|
-
assert parsed[0].arguments == {"command": "git log --oneline -5"}
|
|
654
|
-
|
|
655
|
-
def test_malformed_json_is_skipped(self):
|
|
656
|
-
from omnimancer.core.models import parse_described_tool_calls
|
|
657
|
-
|
|
658
|
-
assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class TestParseDescribedToolCalls:
|
|
597
|
+
"""parse_described_tool_calls recovers calls a model emitted as text.
|
|
598
|
+
|
|
599
|
+
Weak models mimic the "[Called tools: ...]" history notation instead of
|
|
600
|
+
issuing native tool calls; the parser turns that text back into ToolCall
|
|
601
|
+
objects so the agent loop can keep going.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
def test_round_trips_describe_output(self):
|
|
605
|
+
from omnimancer.core.models import (
|
|
606
|
+
ToolCall,
|
|
607
|
+
describe_tool_calls,
|
|
608
|
+
parse_described_tool_calls,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
calls = [
|
|
612
|
+
ToolCall(name="Grep", arguments={"pattern": "auth", "glob": "*.ts"}),
|
|
613
|
+
ToolCall(name="Read", arguments={"file_path": "/src/main.py"}),
|
|
614
|
+
]
|
|
615
|
+
parsed = parse_described_tool_calls(describe_tool_calls(calls))
|
|
616
|
+
|
|
617
|
+
assert [(c.name, c.arguments) for c in parsed] == [
|
|
618
|
+
(c.name, c.arguments) for c in calls
|
|
619
|
+
]
|
|
620
|
+
|
|
621
|
+
def test_plain_text_returns_empty(self):
|
|
622
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
623
|
+
|
|
624
|
+
assert parse_described_tool_calls("The auth flow looks correct.") == []
|
|
625
|
+
assert parse_described_tool_calls("") == []
|
|
626
|
+
assert parse_described_tool_calls(None) == []
|
|
627
|
+
|
|
628
|
+
def test_nested_json_with_braces_and_commas(self):
|
|
629
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
630
|
+
|
|
631
|
+
text = (
|
|
632
|
+
'[Called tools: Grep({"glob": "*.{ts,tsx}", '
|
|
633
|
+
'"output_mode": "files_with_matches", '
|
|
634
|
+
'"pattern": "AUTH_SESSION_KEY|automarketer_auth_session"})]'
|
|
635
|
+
)
|
|
636
|
+
parsed = parse_described_tool_calls(text)
|
|
637
|
+
|
|
638
|
+
assert len(parsed) == 1
|
|
639
|
+
assert parsed[0].name == "Grep"
|
|
640
|
+
assert parsed[0].arguments["glob"] == "*.{ts,tsx}"
|
|
641
|
+
|
|
642
|
+
def test_marker_embedded_in_other_text(self):
|
|
643
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
644
|
+
|
|
645
|
+
text = (
|
|
646
|
+
"Let me search for that.\n"
|
|
647
|
+
'[Called tools: Bash({"command": "git log --oneline -5"})]'
|
|
648
|
+
)
|
|
649
|
+
parsed = parse_described_tool_calls(text)
|
|
650
|
+
|
|
651
|
+
assert len(parsed) == 1
|
|
652
|
+
assert parsed[0].name == "Bash"
|
|
653
|
+
assert parsed[0].arguments == {"command": "git log --oneline -5"}
|
|
654
|
+
|
|
655
|
+
def test_malformed_json_is_skipped(self):
|
|
656
|
+
from omnimancer.core.models import parse_described_tool_calls
|
|
657
|
+
|
|
658
|
+
assert parse_described_tool_calls("[Called tools: Grep({'bad': json})]") == []
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class TestToolCallArgumentNormalization:
|
|
662
|
+
"""ToolCall normalizes arguments that arrive as JSON-encoded strings.
|
|
663
|
+
|
|
664
|
+
OpenAI-protocol servers send function.arguments as a JSON string (and
|
|
665
|
+
some models double-encode it). Providers that passed the raw value
|
|
666
|
+
through crashed the tool handler with "'str' object has no attribute
|
|
667
|
+
'get'".
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
def test_json_string_arguments_become_dict(self):
|
|
671
|
+
from omnimancer.core.models import ToolCall
|
|
672
|
+
|
|
673
|
+
tc = ToolCall(name="Glob", arguments='{"pattern": "**/*.py"}')
|
|
674
|
+
assert tc.arguments == {"pattern": "**/*.py"}
|
|
675
|
+
|
|
676
|
+
def test_double_encoded_arguments_become_dict(self):
|
|
677
|
+
import json
|
|
678
|
+
|
|
679
|
+
from omnimancer.core.models import ToolCall
|
|
680
|
+
|
|
681
|
+
double = json.dumps(json.dumps({"pattern": "TODO"}))
|
|
682
|
+
tc = ToolCall(name="Grep", arguments=double)
|
|
683
|
+
assert tc.arguments == {"pattern": "TODO"}
|
|
684
|
+
|
|
685
|
+
def test_none_arguments_become_empty_dict(self):
|
|
686
|
+
from omnimancer.core.models import ToolCall
|
|
687
|
+
|
|
688
|
+
tc = ToolCall(name="Read", arguments=None)
|
|
689
|
+
assert tc.arguments == {}
|
|
690
|
+
|
|
691
|
+
def test_dict_arguments_unchanged(self):
|
|
692
|
+
from omnimancer.core.models import ToolCall
|
|
693
|
+
|
|
694
|
+
tc = ToolCall(name="Read", arguments={"file_path": "/src/main.py"})
|
|
695
|
+
assert tc.arguments == {"file_path": "/src/main.py"}
|
|
696
|
+
|
|
697
|
+
def test_unparseable_string_left_for_handler_to_report(self):
|
|
698
|
+
from omnimancer.core.models import ToolCall
|
|
699
|
+
|
|
700
|
+
tc = ToolCall(name="Glob", arguments="not json at all")
|
|
701
|
+
assert tc.arguments == "not json at all"
|
|
702
|
+
|
|
703
|
+
def test_non_object_json_left_for_handler_to_report(self):
|
|
704
|
+
from omnimancer.core.models import ToolCall
|
|
705
|
+
|
|
706
|
+
tc = ToolCall(name="Glob", arguments="[1, 2, 3]")
|
|
707
|
+
assert tc.arguments == "[1, 2, 3]"
|