omnimancer-cli 0.2.10__tar.gz → 0.2.13__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.
Files changed (160) hide show
  1. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/agent_loop.py +216 -1
  3. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/command_dispatch.py +5 -2
  4. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/completion.py +13 -1
  5. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/headless.py +24 -0
  6. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/system_prompts.py +182 -5
  7. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/file_system_manager.py +16 -3
  8. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/engine.py +312 -31
  9. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/provider_capabilities.py +52 -13
  10. omnimancer_cli-0.2.13/omnimancer/providers/__init__.py +43 -0
  11. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/digitalocean.py +1 -0
  12. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/openai.py +48 -14
  13. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/perplexity.py +33 -8
  14. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/PKG-INFO +1 -1
  15. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/SOURCES.txt +7 -0
  16. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/pyproject.toml +1 -1
  17. omnimancer_cli-0.2.13/tests/test_bedrock_provider.py +787 -0
  18. omnimancer_cli-0.2.13/tests/test_claude_code_provider.py +624 -0
  19. omnimancer_cli-0.2.13/tests/test_digitalocean_provider.py +190 -0
  20. omnimancer_cli-0.2.13/tests/test_mcp_management_commands.py +655 -0
  21. omnimancer_cli-0.2.13/tests/test_openrouter_provider.py +763 -0
  22. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_perplexity_provider.py +9 -6
  23. omnimancer_cli-0.2.13/tests/test_provider_capability_consistency.py +195 -0
  24. omnimancer_cli-0.2.13/tests/test_provider_discovery.py +93 -0
  25. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_rate_limit_fallback.py +1 -4
  26. omnimancer_cli-0.2.13/tests/test_system_prompts.py +537 -0
  27. omnimancer_cli-0.2.13/tests/test_vertex_provider.py +777 -0
  28. omnimancer_cli-0.2.13/tests/test_web_request_markers.py +951 -0
  29. omnimancer_cli-0.2.10/omnimancer/providers/__init__.py +0 -24
  30. omnimancer_cli-0.2.10/tests/test_digitalocean_provider.py +0 -92
  31. omnimancer_cli-0.2.10/tests/test_provider_capability_consistency.py +0 -95
  32. omnimancer_cli-0.2.10/tests/test_system_prompts.py +0 -43
  33. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/LICENSE +0 -0
  34. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/README.md +0 -0
  35. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/__init__.py +0 -0
  36. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/__main__.py +0 -0
  37. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/__init__.py +0 -0
  38. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/approval_formatter.py +0 -0
  39. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/approval_integration.py +0 -0
  40. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/approval_prompt.py +0 -0
  41. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/commands.py +0 -0
  42. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/display.py +0 -0
  43. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/dynamic_commands.py +0 -0
  44. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/handlers/__init__.py +0 -0
  45. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/interface.py +0 -0
  46. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/subagent.py +0 -0
  47. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/cli/tool_handler.py +0 -0
  48. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/__init__.py +0 -0
  49. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/__init__.py +0 -0
  50. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/approval_context.py +0 -0
  51. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/approval_interface.py +0 -0
  52. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/approval_manager.py +0 -0
  53. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/config.py +0 -0
  54. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/program_executor.py +0 -0
  55. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  56. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/status_core.py +0 -0
  57. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/status_manager.py +0 -0
  58. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/tool_definitions.py +0 -0
  59. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/types.py +0 -0
  60. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/web_client.py +0 -0
  61. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  62. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent_engine.py +0 -0
  63. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent_managers.py +0 -0
  64. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/agent_mode_manager.py +0 -0
  65. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/chat_manager.py +0 -0
  66. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/config_manager.py +0 -0
  67. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/config_migration.py +0 -0
  68. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/config_validator.py +0 -0
  69. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/conversation_manager.py +0 -0
  70. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/env_loader.py +0 -0
  71. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/fallback_manager.py +0 -0
  72. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/health_monitor.py +0 -0
  73. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/history_manager.py +0 -0
  74. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/hooks.py +0 -0
  75. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/mcp_integration_layer.py +0 -0
  76. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/models.py +0 -0
  77. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/provider_initializer.py +0 -0
  78. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/provider_registry.py +0 -0
  79. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/rate_limit_fallback.py +0 -0
  80. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/__init__.py +0 -0
  81. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/approval_workflow.py +0 -0
  82. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/audit_logger.py +0 -0
  83. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/permission_controller.py +0 -0
  84. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/permission_rules.py +0 -0
  85. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/sandbox_manager.py +0 -0
  86. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/security/security_manager.py +0 -0
  87. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/core/signal_handler.py +0 -0
  88. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/mcp/__init__.py +0 -0
  89. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/mcp/client.py +0 -0
  90. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/mcp/manager.py +0 -0
  91. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/azure.py +0 -0
  92. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/base.py +0 -0
  93. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/bedrock.py +0 -0
  94. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/claude.py +0 -0
  95. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/claude_code.py +0 -0
  96. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/claude_credentials.py +0 -0
  97. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/cohere.py +0 -0
  98. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/factory.py +0 -0
  99. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/gemini.py +0 -0
  100. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/mistral.py +0 -0
  101. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/ollama.py +0 -0
  102. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/openrouter.py +0 -0
  103. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/vertex.py +0 -0
  104. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/providers/xai.py +0 -0
  105. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/ui/__init__.py +0 -0
  106. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/ui/cancellation_handler.py +0 -0
  107. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/ui/progress_indicator.py +0 -0
  108. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/ui/streaming_display.py +0 -0
  109. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/utils/__init__.py +0 -0
  110. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/utils/error_handler.py +0 -0
  111. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/utils/errors.py +0 -0
  112. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer/utils/retry.py +0 -0
  113. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  114. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  115. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/requires.txt +0 -0
  116. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/omnimancer_cli.egg-info/top_level.txt +0 -0
  117. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/setup.cfg +0 -0
  118. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_approval_integration_compatibility.py +0 -0
  119. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_approval_preview_format.py +0 -0
  120. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_approval_remember.py +0 -0
  121. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_approval_system.py +0 -0
  122. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_azure_provider.py +0 -0
  123. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_claude_credentials.py +0 -0
  124. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_claude_provider.py +0 -0
  125. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_cohere_provider.py +0 -0
  126. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_command_approval_security.py +0 -0
  127. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_config_comprehensive.py +0 -0
  128. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_conversation_history.py +0 -0
  129. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_conversation_manager.py +0 -0
  130. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_core_engine.py +0 -0
  131. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_enhanced_model_info.py +0 -0
  132. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_env_loader.py +0 -0
  133. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_error_handling_robustness.py +0 -0
  134. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_execution_timeout_errors.py +0 -0
  135. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_fallback_manager.py +0 -0
  136. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_fixtures.py +0 -0
  137. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_gemini_provider.py +0 -0
  138. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_hooks.py +0 -0
  139. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_httpx_compatibility.py +0 -0
  140. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_import_validation.py +0 -0
  141. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_interface_smoke_tests.py +0 -0
  142. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_mcp_integration.py +0 -0
  143. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_mcp_integration_layer.py +0 -0
  144. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_mistral_provider.py +0 -0
  145. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_models.py +0 -0
  146. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_ollama_provider.py +0 -0
  147. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_openai_context_fit.py +0 -0
  148. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_openai_provider_tools.py +0 -0
  149. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_permission_rules.py +0 -0
  150. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_program_executor.py +0 -0
  151. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_provider_base.py +0 -0
  152. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_provider_integration.py +0 -0
  153. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_provider_registry.py +0 -0
  154. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_retry.py +0 -0
  155. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_security_validation.py +0 -0
  156. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_system_integration.py +0 -0
  157. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_tool_calling_integration.py +0 -0
  158. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_tool_handler.py +0 -0
  159. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_workflows_comprehensive.py +0 -0
  160. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.13}/tests/test_xai_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omnimancer-cli
3
- Version: 0.2.10
3
+ Version: 0.2.13
4
4
  Summary: A unified command-line interface for multiple AI language models
5
5
  Author-email: Kellan Strong <kellan@jite.ai>
6
6
  License: MIT
@@ -6,6 +6,7 @@ via the agent engine, with a continuous workflow loop for multi-step tasks.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import ipaddress
9
10
  import logging
10
11
  import re
11
12
  import shlex
@@ -13,9 +14,98 @@ import subprocess
13
14
  from difflib import SequenceMatcher
14
15
  from pathlib import Path
15
16
  from typing import Any, Optional
17
+ from urllib.parse import urlparse, urlunparse
16
18
 
17
19
  from ..core.agent.types import Operation, OperationType
18
20
 
21
+ # ---------------------------------------------------------------------------
22
+ # Web request constants and helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ #: Maximum characters returned from a single web response.
26
+ _MAX_WEB_RESPONSE_CHARS: int = 10_000
27
+
28
+ #: Hostnames that must never be contacted – cloud/VM metadata endpoints.
29
+ _BLOCKED_METADATA_HOSTNAMES: frozenset = frozenset(
30
+ {
31
+ "169.254.169.254", # AWS IMDSv1/v2, GCP, Azure shared endpoint
32
+ "metadata.google.internal", # GCP metadata server
33
+ "fd00:ec2::254", # AWS IPv6 IMDS
34
+ "metadata.internal", # Azure internal alias
35
+ }
36
+ )
37
+
38
+ #: Compiled patterns for web operation markers.
39
+ _WEB_GET_PATTERN = re.compile(r"\[WEB_GET:([^\]]+)\]")
40
+ _WEB_REQUEST_PATTERN = re.compile(r"\[WEB_REQUEST:([^\]]+)\]")
41
+ _WEB_POST_PATTERN = re.compile(r"\[WEB_POST:([^\]]+)\]")
42
+
43
+
44
+ def _check_url_safety(url: str) -> Optional[str]:
45
+ """Validate whether *url* is safe to request.
46
+
47
+ Enforces:
48
+ - HTTP or HTTPS schemes only.
49
+ - No embedded credentials (``user:pass@host``).
50
+ - No loopback, private, link-local, or reserved addresses.
51
+ - No known cloud/VM metadata endpoints.
52
+
53
+ Returns an error-message string when the URL is unsafe, ``None`` if OK.
54
+ """
55
+ try:
56
+ parsed = urlparse(url.strip())
57
+ except Exception:
58
+ return "Invalid URL format"
59
+
60
+ if parsed.scheme not in ("http", "https"):
61
+ return f"Unsupported scheme '{parsed.scheme}':" " only http/https allowed"
62
+
63
+ if parsed.username or parsed.password:
64
+ return "URLs with embedded credentials are not allowed"
65
+
66
+ hostname = parsed.hostname
67
+ if not hostname:
68
+ return "URL has no hostname"
69
+
70
+ hostname_lower = hostname.lower()
71
+
72
+ # Block well-known localhost names before IP parsing.
73
+ if hostname_lower in ("localhost", "::1", "0.0.0.0"):
74
+ return f"Requests to '{hostname}' are not allowed"
75
+
76
+ # Block cloud metadata hostnames.
77
+ if hostname_lower in _BLOCKED_METADATA_HOSTNAMES:
78
+ return f"Requests to cloud metadata endpoint '{hostname}' are blocked"
79
+
80
+ # Reject private/reserved IP addresses.
81
+ try:
82
+ addr = ipaddress.ip_address(hostname_lower)
83
+ if (
84
+ addr.is_private
85
+ or addr.is_loopback
86
+ or addr.is_link_local
87
+ or addr.is_reserved
88
+ ):
89
+ return f"Requests to private/reserved IP '{hostname}'" " are not allowed"
90
+ except ValueError:
91
+ pass # Hostname is a domain name – that's fine.
92
+
93
+ return None # URL passed all safety checks.
94
+
95
+
96
+ def _sanitize_url_for_log(url: str) -> str:
97
+ """Return *url* with credentials and query-string stripped for safe logging."""
98
+ try:
99
+ parsed = urlparse(url)
100
+ netloc = parsed.hostname or ""
101
+ if parsed.port:
102
+ netloc = f"{netloc}:{parsed.port}"
103
+ safe = parsed._replace(netloc=netloc, query="", fragment="")
104
+ return urlunparse(safe)
105
+ except Exception:
106
+ return "<invalid-url>"
107
+
108
+
19
109
  logger = logging.getLogger(__name__)
20
110
 
21
111
 
@@ -32,6 +122,113 @@ class AgentLoopMixin:
32
122
  def _show_assistant_message(self, content: str, model: str) -> None: ...
33
123
  def _show_error(self, message: str) -> None: ...
34
124
 
125
+ async def _execute_web_request(
126
+ self,
127
+ marker_text: str,
128
+ url: str,
129
+ http_method: str,
130
+ current_response: str,
131
+ ) -> tuple[str, bool]:
132
+ """Execute a single web-request marker and return the updated response.
133
+
134
+ Validates the *url* for safety, requests user approval, issues the
135
+ request via the agent engine, and inlines the result.
136
+
137
+ Args:
138
+ marker_text: The raw marker string to replace (e.g. ``[WEB_GET:…]``).
139
+ url: The target URL extracted from the marker.
140
+ http_method: HTTP verb – ``"GET"`` or ``"POST"``.
141
+ current_response: The response text being built up.
142
+
143
+ Returns:
144
+ A ``(updated_response, was_cancelled)`` tuple. When ``was_cancelled``
145
+ is ``True`` the returned string already contains the
146
+ ``__WORKFLOW_CANCELLED__`` sentinel and the caller must return
147
+ immediately.
148
+ """
149
+ safe_url = _sanitize_url_for_log(url)
150
+
151
+ url_error = _check_url_safety(url)
152
+ if url_error:
153
+ return (
154
+ current_response.replace(
155
+ marker_text,
156
+ f"❌ Web request blocked: {url_error}",
157
+ ),
158
+ False,
159
+ )
160
+
161
+ agent_engine = getattr(self.engine, "agent_engine", None)
162
+ if not agent_engine:
163
+ return (
164
+ current_response.replace(
165
+ marker_text,
166
+ "❌ Agent engine not available"
167
+ " - web requests disabled for security",
168
+ ),
169
+ False,
170
+ )
171
+
172
+ operation = Operation(
173
+ type=OperationType.WEB_REQUEST,
174
+ description=f"{http_method} request to: {safe_url}",
175
+ data={"url": url, "method": http_method},
176
+ requires_approval=True,
177
+ )
178
+
179
+ try:
180
+ result = await agent_engine.execute_with_approval(operation)
181
+
182
+ if result.success:
183
+ if isinstance(result.data, dict):
184
+ content = result.data.get("content", "")
185
+ if not isinstance(content, str):
186
+ content = str(content)
187
+ else:
188
+ content = str(result.data or "")
189
+
190
+ if len(content) > _MAX_WEB_RESPONSE_CHARS:
191
+ content = (
192
+ content[:_MAX_WEB_RESPONSE_CHARS]
193
+ + "\n\n[... response truncated ...]"
194
+ )
195
+
196
+ return (
197
+ current_response.replace(
198
+ marker_text,
199
+ f"🌐 {http_method} {safe_url}\n{content}",
200
+ ),
201
+ False,
202
+ )
203
+ else:
204
+ if result.was_cancelled:
205
+ updated = current_response.replace(
206
+ marker_text,
207
+ "🚫 Agent workflow cancelled by user",
208
+ )
209
+ return updated + "\n\n__WORKFLOW_CANCELLED__", True
210
+ else:
211
+ return (
212
+ current_response.replace(
213
+ marker_text,
214
+ f"❌ Web request failed: {result.error}",
215
+ ),
216
+ False,
217
+ )
218
+
219
+ except Exception as e:
220
+ logger.error(
221
+ f"Web request execution failed: {e}",
222
+ exc_info=True,
223
+ )
224
+ return (
225
+ current_response.replace(
226
+ marker_text,
227
+ f"❌ Web request error: {str(e)}",
228
+ ),
229
+ False,
230
+ )
231
+
35
232
  async def _execute_continuous_workflow(
36
233
  self, original_message: str, initial_response: Any
37
234
  ) -> None:
@@ -68,7 +265,9 @@ class AgentLoopMixin:
68
265
  r"\[FILE_WRITE:[^\]]+\\?\].*?\[/FILE_WRITE\]",
69
266
  r"\[FILE_READ:[^\]]+\\?\]",
70
267
  r"\[COMMAND_EXEC\].*?\[/COMMAND_EXEC\]",
71
- r"\[WEB_REQUEST:[^\]]+\\?\]",
268
+ r"\[WEB_REQUEST:[^\]]+\]",
269
+ r"\[WEB_GET:[^\]]+\]",
270
+ r"\[WEB_POST:[^\]]+\]",
72
271
  r"\[SAFE_EXEC\].*?\[/SAFE_EXEC\]",
73
272
  ]
74
273
 
@@ -622,6 +821,22 @@ class AgentLoopMixin:
622
821
  f"❌ Command execution error: {str(e)}",
623
822
  )
624
823
 
824
+ # WEB_GET, WEB_REQUEST, and WEB_POST markers – each handled via the
825
+ # shared _execute_web_request helper which validates safety, requests
826
+ # user approval, and inlines the response content.
827
+ for marker_pattern, http_method in (
828
+ (_WEB_GET_PATTERN, "GET"),
829
+ (_WEB_REQUEST_PATTERN, "GET"),
830
+ (_WEB_POST_PATTERN, "POST"),
831
+ ):
832
+ for match in marker_pattern.finditer(response_content):
833
+ url = match.group(1).strip()
834
+ updated_response, cancelled = await self._execute_web_request(
835
+ match.group(0), url, http_method, updated_response
836
+ )
837
+ if cancelled:
838
+ return updated_response
839
+
625
840
  except Exception as e:
626
841
  self._show_error(f"Error parsing operations: {e}")
627
842
  return response_content
@@ -1259,7 +1259,10 @@ class CommandDispatchMixin:
1259
1259
 
1260
1260
  def _show_config_help(self) -> None:
1261
1261
  """Show configuration command help."""
1262
- help_text = """[bold]Configuration Commands:[/bold]
1262
+ from ..providers.factory import ProviderFactory
1263
+
1264
+ endpoints = ", ".join(sorted(ProviderFactory.get_available_providers()))
1265
+ help_text = f"""[bold]Configuration Commands:[/bold]
1263
1266
 
1264
1267
  [cyan]/config[/cyan] - Show current configuration
1265
1268
  [cyan]/config show[/cyan] - Show current configuration
@@ -1273,7 +1276,7 @@ class CommandDispatchMixin:
1273
1276
  [cyan]/config remove-provider <name>[/cyan] - Remove a provider
1274
1277
 
1275
1278
  [bold]Endpoints[/bold] (each is a provider you can configure):
1276
- claude, openrouter, openai, digitalocean
1279
+ {endpoints}
1277
1280
 
1278
1281
  [bold]Examples:[/bold]
1279
1282
  /config set-provider digitalocean --api-key $DO_KEY \\
@@ -26,7 +26,19 @@ class CompletionManager:
26
26
  command = command[1:]
27
27
 
28
28
  static_completions = {
29
- "mcp": {0: ["status", "reload", "connect", "disconnect", "health"]},
29
+ "mcp": {
30
+ 0: [
31
+ "status",
32
+ "reload",
33
+ "connect",
34
+ "disconnect",
35
+ "health",
36
+ "servers",
37
+ "tools",
38
+ "add",
39
+ "remove",
40
+ ]
41
+ },
30
42
  "history": {0: ["list", "clear", "export", "import"]},
31
43
  "config": {
32
44
  0: [
@@ -234,12 +234,36 @@ class HeadlessRunner:
234
234
  self._emitter = HeadlessOutputEmitter(output_format, session_id, verbose)
235
235
  self._tokens = TokenAccumulator()
236
236
 
237
+ @staticmethod
238
+ def _enable_auto_approval(agent_engine: Any) -> None:
239
+ """Approve every operation, honoring --dangerously-skip-permissions.
240
+
241
+ Headless runs are unattended: no approval callback is ever installed,
242
+ and both approval managers deny by default without one, so a headless
243
+ agent could never write a file or run a command regardless of the
244
+ flag. Config-driven permission DENY rules and hooks still apply — they
245
+ are checked before the approval step.
246
+ """
247
+
248
+ async def _approve(_request: Any) -> bool:
249
+ return True
250
+
251
+ approval = getattr(agent_engine, "approval", None)
252
+ if approval is not None:
253
+ approval.set_approval_callback(_approve)
254
+ enhanced = getattr(agent_engine, "enhanced_approval", None)
255
+ if enhanced is not None:
256
+ enhanced.set_approval_callback(_approve)
257
+
237
258
  async def run(self, prompt: str) -> int:
238
259
  agent_engine = getattr(self._engine, "agent_engine", None)
239
260
  if not agent_engine:
240
261
  self._emitter.emit_error("Agent engine not available.")
241
262
  return 1
242
263
 
264
+ if self._no_approval:
265
+ self._enable_auto_approval(agent_engine)
266
+
243
267
  model = self._engine.config_manager.get_config().default_provider or "unknown"
244
268
  self._emitter.emit_init(model)
245
269
 
@@ -5,7 +5,173 @@ This module contains the system prompts used to guide the AI agent's behavior,
5
5
  including capability descriptions, operation markers, and execution patterns.
6
6
  """
7
7
 
8
+ import logging
9
+ import re
8
10
  from pathlib import Path
11
+ from typing import List, Optional, Tuple
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Maximum bytes read from any single instruction file. Prevents memory
16
+ # exhaustion if a file is unexpectedly large (accidental or malicious).
17
+ _MAX_INSTRUCTION_FILE_BYTES: int = 100_000 # 100 KB
18
+
19
+ # Regex that matches fenced code blocks (``` … ``` or ~~~ … ~~~).
20
+ # These are stripped before insertion to prevent an attacker from embedding
21
+ # shell commands that the model might treat as executable instructions.
22
+ _FENCED_CODE_BLOCK_RE = re.compile(
23
+ r"^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)\s*$",
24
+ re.MULTILINE | re.DOTALL,
25
+ )
26
+
27
+ # Regex for control characters that are not ordinary whitespace.
28
+ # Null bytes and C0/C1 control characters can confuse tokenisers or be used
29
+ # to smuggle hidden instructions.
30
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
31
+
32
+
33
+ def _sanitize_instruction_content(raw: str) -> str:
34
+ """Sanitize raw text loaded from an instruction file.
35
+
36
+ Applies the following transforms in order:
37
+
38
+ 1. **Strip fenced code blocks** – removes triple-backtick / triple-tilde
39
+ blocks so embedded shell snippets are not mistaken for executable
40
+ commands by the model.
41
+
42
+ 2. **Remove control characters** – null bytes and non-printable C0/C1
43
+ control characters are deleted; ordinary whitespace (\\n, \\r, \\t,
44
+ space) is preserved.
45
+
46
+ 3. **Collapse excess blank lines** – more than two consecutive blank lines
47
+ are collapsed to two, keeping the text readable without allowing
48
+ unlimited vertical padding.
49
+
50
+ 4. **Truncate to length limit** – content is capped at
51
+ ``_MAX_INSTRUCTION_FILE_BYTES`` characters (which is already enforced
52
+ at the read stage, but this acts as a belt-and-suspenders guard).
53
+
54
+ Args:
55
+ raw: The raw string read from an instruction file.
56
+
57
+ Returns:
58
+ Sanitized string (may be empty if everything was stripped).
59
+ """
60
+ # 1. Remove fenced code blocks
61
+ text = _FENCED_CODE_BLOCK_RE.sub("", raw)
62
+
63
+ # 2. Remove dangerous control characters (keep \n \r \t and space)
64
+ text = _CONTROL_CHAR_RE.sub("", text)
65
+
66
+ # 3. Collapse excessive blank lines
67
+ text = re.sub(r"\n{3,}", "\n\n", text)
68
+
69
+ # 4. Belt-and-suspenders length cap (file read is already capped)
70
+ text = text[:_MAX_INSTRUCTION_FILE_BYTES]
71
+
72
+ return text.strip()
73
+
74
+
75
+ def _read_instruction_file(path: Path) -> Optional[str]:
76
+ """Read and sanitize an instruction file, enforcing a size cap.
77
+
78
+ Returns sanitized content string, or ``None`` if the file is unreadable,
79
+ empty, or the sanitized result is empty.
80
+ """
81
+ try:
82
+ # Read only up to the byte cap; do not load the entire file first.
83
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
84
+ raw = fh.read(_MAX_INSTRUCTION_FILE_BYTES)
85
+ except OSError as exc:
86
+ logger.debug("Failed to read instruction file %s: %s", path, exc)
87
+ return None
88
+
89
+ sanitized = _sanitize_instruction_content(raw)
90
+ return sanitized if sanitized else None
91
+
92
+
93
+ def load_project_instructions() -> str:
94
+ """Load user-defined instructions from OMNIMANCER.md or CLAUDE.md files.
95
+
96
+ Searches for instruction files and combines them in priority order
97
+ (later entries are more specific and appear last in the prompt):
98
+
99
+ 1. Global persona: ``~/.omnimancer/OMNIMANCER.md``
100
+ User-level defaults and persona definitions, always loaded if present.
101
+
102
+ 2. Project ``CLAUDE.md``: nearest ancestor of CWD (Claude Code compatibility).
103
+ Useful when a project already ships a CLAUDE.md for Claude Code users.
104
+
105
+ 3. Project ``OMNIMANCER.md``: nearest ancestor of CWD (highest priority).
106
+ Omnimancer-specific instructions that override everything else.
107
+
108
+ The upward walk stops at the first ``.git`` directory so instructions
109
+ stay scoped to the project. Files above the git root are ignored as
110
+ project files (only the global config is loaded from ``~/.omnimancer/``).
111
+
112
+ Files that are empty, unreadable, or exceed the size limit are silently
113
+ skipped. Content is sanitised before insertion (see
114
+ :func:`_sanitize_instruction_content`).
115
+
116
+ Returns:
117
+ Formatted instruction block string, or empty string if no files found.
118
+ """
119
+ found: List[Tuple[str, str]] = [] # (label, content)
120
+
121
+ # 1. Global persona / user-level defaults
122
+ global_file = Path.home() / ".omnimancer" / "OMNIMANCER.md"
123
+ if global_file.exists():
124
+ content = _read_instruction_file(global_file)
125
+ if content:
126
+ found.append(("~/.omnimancer/OMNIMANCER.md", content))
127
+
128
+ # 2 & 3. Walk up from CWD to the git root (or filesystem root) looking
129
+ # for project-level CLAUDE.md and OMNIMANCER.md. We collect the *nearest*
130
+ # occurrence of each so subdirectory instructions win over parent ones.
131
+ claude_md_path: Optional[Path] = None
132
+ omnimancer_md_path: Optional[Path] = None
133
+
134
+ check_dir = Path.cwd()
135
+ while True:
136
+ if claude_md_path is None:
137
+ candidate = check_dir / "CLAUDE.md"
138
+ if candidate.exists():
139
+ claude_md_path = candidate
140
+ if omnimancer_md_path is None:
141
+ candidate = check_dir / "OMNIMANCER.md"
142
+ if candidate.exists():
143
+ omnimancer_md_path = candidate
144
+
145
+ # Stop at the git root so we don't wander out of the project
146
+ if (check_dir / ".git").exists():
147
+ break
148
+ parent = check_dir.parent
149
+ if parent == check_dir:
150
+ break # filesystem root
151
+ check_dir = parent
152
+
153
+ if claude_md_path is not None:
154
+ content = _read_instruction_file(claude_md_path)
155
+ if content:
156
+ found.append((claude_md_path.name, content))
157
+
158
+ if omnimancer_md_path is not None:
159
+ content = _read_instruction_file(omnimancer_md_path)
160
+ if content:
161
+ found.append((omnimancer_md_path.name, content))
162
+
163
+ if not found:
164
+ return ""
165
+
166
+ # Wrap with a clear "user-provided" label so the model cannot be tricked
167
+ # into treating this content as authoritative system instructions.
168
+ parts = [
169
+ "\n📋 CUSTOM INSTRUCTIONS (user-provided — not system authority):",
170
+ ]
171
+ for label, content in found:
172
+ parts.append(f"\n--- {label} ---\n{content}")
173
+
174
+ return "\n".join(parts)
9
175
 
10
176
 
11
177
  def get_directory_context() -> str:
@@ -189,9 +355,8 @@ Examples:
189
355
  pdf2docx[/COMMAND_EXEC]
190
356
 
191
357
  🌐 Web Operations:
192
- - [WEB_REQUEST:url] - Make a web request
193
- - [WEB_GET:url] - HTTP GET request
194
- - [WEB_POST:url] data [/WEB_POST] - HTTP POST request
358
+ - [WEB_REQUEST:url] - HTTP GET request to the given URL
359
+ - [WEB_GET:url] - HTTP GET request to the given URL
195
360
 
196
361
  SECURITY & APPROVAL SYSTEM:
197
362
  - File writes are AUTOMATICALLY shown to user for approval
@@ -317,6 +482,7 @@ def build_agent_prompt(supports_tools: bool = False) -> str:
317
482
  System prompt appropriate for the provider's capabilities
318
483
  """
319
484
  directory_info = get_directory_context()
485
+ custom_instructions = load_project_instructions()
320
486
 
321
487
  sections = [
322
488
  (
@@ -338,6 +504,9 @@ def build_agent_prompt(supports_tools: bool = False) -> str:
338
504
  sections.append(OPERATION_MARKERS_SECTION)
339
505
  sections.append(EXECUTION_EXAMPLES_SECTION)
340
506
 
507
+ if custom_instructions:
508
+ sections.append(custom_instructions)
509
+
341
510
  return "\n".join(sections)
342
511
 
343
512
 
@@ -352,6 +521,8 @@ def get_agent_capabilities_prompt() -> str:
352
521
  Complete system prompt string with directory context
353
522
  """
354
523
  directory_info = get_directory_context()
524
+ custom_instructions = load_project_instructions()
525
+ custom_block = f"\n{custom_instructions}" if custom_instructions else ""
355
526
 
356
527
  return f"""SYSTEM: You are an autonomous AI agent with \
357
528
  the ability to perform actions on the local system. \
@@ -364,7 +535,7 @@ You have the following capabilities:
364
535
  {SECURITY_FEATURES_SECTION}
365
536
  {AGENT_EXECUTION_PATTERN_SECTION}
366
537
  {OPERATION_MARKERS_SECTION}
367
- {EXECUTION_EXAMPLES_SECTION}"""
538
+ {EXECUTION_EXAMPLES_SECTION}{custom_block}"""
368
539
 
369
540
 
370
541
  def get_minimal_prompt() -> str:
@@ -378,6 +549,8 @@ def get_minimal_prompt() -> str:
378
549
  Minimal system prompt string
379
550
  """
380
551
  directory_info = get_directory_context()
552
+ custom_instructions = load_project_instructions()
553
+ custom_block = f"\n{custom_instructions}" if custom_instructions else ""
381
554
 
382
555
  return f"""SYSTEM: You are an autonomous AI agent with \
383
556
  the ability to perform actions on the local system.
@@ -386,7 +559,7 @@ the ability to perform actions on the local system.
386
559
  {COMMAND_EXECUTION_SECTION}
387
560
  {WEB_OPERATIONS_SECTION}
388
561
  {SECURITY_FEATURES_SECTION}
389
- {OPERATION_MARKERS_SECTION}"""
562
+ {OPERATION_MARKERS_SECTION}{custom_block}"""
390
563
 
391
564
 
392
565
  def get_custom_prompt(
@@ -406,6 +579,7 @@ def get_custom_prompt(
406
579
  Custom system prompt string
407
580
  """
408
581
  directory_info = get_directory_context()
582
+ custom_instructions = load_project_instructions()
409
583
 
410
584
  sections = [
411
585
  (
@@ -432,4 +606,7 @@ def get_custom_prompt(
432
606
  if include_examples:
433
607
  sections.append(EXECUTION_EXAMPLES_SECTION)
434
608
 
609
+ if custom_instructions:
610
+ sections.append(custom_instructions)
611
+
435
612
  return "\n".join(sections)
@@ -375,13 +375,22 @@ class FileSystemManager:
375
375
  path: Union[str, Path],
376
376
  encoding: str = "utf-8",
377
377
  binary: bool = False,
378
+ allow_sensitive: bool = False,
378
379
  ) -> Union[str, bytes]:
379
- """Read file content with security validation and error handling."""
380
+ """Read file content with security validation and error handling.
381
+
382
+ ``allow_sensitive`` permits reading sensitive project-local files (e.g.
383
+ ``.env``). It is set for internal reads that are part of an already
384
+ approved operation — such as the read-before-write diff/backup step —
385
+ so the existing content can be shown and preserved.
386
+ """
380
387
 
381
388
  path = Path(path).resolve()
382
389
 
383
390
  # Security validation
384
- result = await self.security.secure_file_access(str(path), "read")
391
+ result = await self.security.secure_file_access(
392
+ str(path), "read", allow_sensitive=allow_sensitive
393
+ )
385
394
  if not result["success"]:
386
395
  raise FileOperationError(f"Security check failed: {result['error']}")
387
396
 
@@ -1284,7 +1293,11 @@ class FileSystemManager:
1284
1293
 
1285
1294
  if file_exists:
1286
1295
  try:
1287
- current_content = await self.read_file(path, encoding=encoding)
1296
+ # This read is part of an approved write; allow sensitive
1297
+ # files (e.g. .env) so the diff and backup still work.
1298
+ current_content = await self.read_file(
1299
+ path, encoding=encoding, allow_sensitive=True
1300
+ )
1288
1301
  except Exception as e:
1289
1302
  # Handle file read error
1290
1303
  read_error = FileReadError(str(path), e)