omnimancer-cli 0.2.10__tar.gz → 0.2.15__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.15}/PKG-INFO +1 -1
  2. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/agent_loop.py +216 -1
  3. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/command_dispatch.py +5 -2
  4. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/completion.py +13 -1
  5. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/headless.py +45 -0
  6. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/system_prompts.py +182 -5
  7. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/file_system_manager.py +24 -3
  8. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/program_executor.py +9 -0
  9. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent_managers.py +24 -0
  10. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/engine.py +312 -31
  11. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/provider_capabilities.py +52 -13
  12. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/permission_controller.py +16 -1
  13. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/security_manager.py +11 -0
  14. omnimancer_cli-0.2.15/omnimancer/providers/__init__.py +43 -0
  15. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/digitalocean.py +1 -0
  16. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/openai.py +48 -14
  17. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/perplexity.py +33 -8
  18. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/PKG-INFO +1 -1
  19. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/SOURCES.txt +7 -0
  20. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/pyproject.toml +1 -1
  21. omnimancer_cli-0.2.15/tests/test_bedrock_provider.py +787 -0
  22. omnimancer_cli-0.2.15/tests/test_claude_code_provider.py +624 -0
  23. omnimancer_cli-0.2.15/tests/test_digitalocean_provider.py +190 -0
  24. omnimancer_cli-0.2.15/tests/test_mcp_management_commands.py +655 -0
  25. omnimancer_cli-0.2.15/tests/test_openrouter_provider.py +763 -0
  26. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_perplexity_provider.py +9 -6
  27. omnimancer_cli-0.2.15/tests/test_provider_capability_consistency.py +195 -0
  28. omnimancer_cli-0.2.15/tests/test_provider_discovery.py +93 -0
  29. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_rate_limit_fallback.py +1 -4
  30. omnimancer_cli-0.2.15/tests/test_system_prompts.py +537 -0
  31. omnimancer_cli-0.2.15/tests/test_vertex_provider.py +777 -0
  32. omnimancer_cli-0.2.15/tests/test_web_request_markers.py +951 -0
  33. omnimancer_cli-0.2.10/omnimancer/providers/__init__.py +0 -24
  34. omnimancer_cli-0.2.10/tests/test_digitalocean_provider.py +0 -92
  35. omnimancer_cli-0.2.10/tests/test_provider_capability_consistency.py +0 -95
  36. omnimancer_cli-0.2.10/tests/test_system_prompts.py +0 -43
  37. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/LICENSE +0 -0
  38. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/README.md +0 -0
  39. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/__init__.py +0 -0
  40. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/__main__.py +0 -0
  41. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/__init__.py +0 -0
  42. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/approval_formatter.py +0 -0
  43. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/approval_integration.py +0 -0
  44. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/approval_prompt.py +0 -0
  45. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/commands.py +0 -0
  46. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/display.py +0 -0
  47. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/dynamic_commands.py +0 -0
  48. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/handlers/__init__.py +0 -0
  49. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/interface.py +0 -0
  50. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/subagent.py +0 -0
  51. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/cli/tool_handler.py +0 -0
  52. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/__init__.py +0 -0
  53. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/__init__.py +0 -0
  54. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/approval_context.py +0 -0
  55. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/approval_interface.py +0 -0
  56. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/approval_manager.py +0 -0
  57. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/config.py +0 -0
  58. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/read_before_write_errors.py +0 -0
  59. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/status_core.py +0 -0
  60. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/status_manager.py +0 -0
  61. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/tool_definitions.py +0 -0
  62. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/types.py +0 -0
  63. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/web_client.py +0 -0
  64. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent/workflow_orchestrator.py +0 -0
  65. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent_engine.py +0 -0
  66. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/agent_mode_manager.py +0 -0
  67. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/chat_manager.py +0 -0
  68. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/config_manager.py +0 -0
  69. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/config_migration.py +0 -0
  70. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/config_validator.py +0 -0
  71. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/conversation_manager.py +0 -0
  72. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/env_loader.py +0 -0
  73. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/fallback_manager.py +0 -0
  74. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/health_monitor.py +0 -0
  75. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/history_manager.py +0 -0
  76. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/hooks.py +0 -0
  77. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/mcp_integration_layer.py +0 -0
  78. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/models.py +0 -0
  79. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/provider_initializer.py +0 -0
  80. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/provider_registry.py +0 -0
  81. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/rate_limit_fallback.py +0 -0
  82. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/__init__.py +0 -0
  83. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/approval_workflow.py +0 -0
  84. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/audit_logger.py +0 -0
  85. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/permission_rules.py +0 -0
  86. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/security/sandbox_manager.py +0 -0
  87. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/core/signal_handler.py +0 -0
  88. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/mcp/__init__.py +0 -0
  89. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/mcp/client.py +0 -0
  90. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/mcp/manager.py +0 -0
  91. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/azure.py +0 -0
  92. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/base.py +0 -0
  93. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/bedrock.py +0 -0
  94. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/claude.py +0 -0
  95. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/claude_code.py +0 -0
  96. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/claude_credentials.py +0 -0
  97. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/cohere.py +0 -0
  98. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/factory.py +0 -0
  99. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/gemini.py +0 -0
  100. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/mistral.py +0 -0
  101. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/ollama.py +0 -0
  102. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/openrouter.py +0 -0
  103. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/vertex.py +0 -0
  104. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/providers/xai.py +0 -0
  105. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/ui/__init__.py +0 -0
  106. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/ui/cancellation_handler.py +0 -0
  107. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/ui/progress_indicator.py +0 -0
  108. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/ui/streaming_display.py +0 -0
  109. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/utils/__init__.py +0 -0
  110. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/utils/error_handler.py +0 -0
  111. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/utils/errors.py +0 -0
  112. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer/utils/retry.py +0 -0
  113. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/dependency_links.txt +0 -0
  114. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/entry_points.txt +0 -0
  115. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/requires.txt +0 -0
  116. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/omnimancer_cli.egg-info/top_level.txt +0 -0
  117. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/setup.cfg +0 -0
  118. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_approval_integration_compatibility.py +0 -0
  119. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_approval_preview_format.py +0 -0
  120. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_approval_remember.py +0 -0
  121. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_approval_system.py +0 -0
  122. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_azure_provider.py +0 -0
  123. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_claude_credentials.py +0 -0
  124. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_claude_provider.py +0 -0
  125. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_cohere_provider.py +0 -0
  126. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_command_approval_security.py +0 -0
  127. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_config_comprehensive.py +0 -0
  128. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_conversation_history.py +0 -0
  129. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_conversation_manager.py +0 -0
  130. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_core_engine.py +0 -0
  131. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_enhanced_model_info.py +0 -0
  132. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_env_loader.py +0 -0
  133. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_error_handling_robustness.py +0 -0
  134. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_execution_timeout_errors.py +0 -0
  135. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_fallback_manager.py +0 -0
  136. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_fixtures.py +0 -0
  137. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_gemini_provider.py +0 -0
  138. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_hooks.py +0 -0
  139. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_httpx_compatibility.py +0 -0
  140. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_import_validation.py +0 -0
  141. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_interface_smoke_tests.py +0 -0
  142. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_mcp_integration.py +0 -0
  143. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_mcp_integration_layer.py +0 -0
  144. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_mistral_provider.py +0 -0
  145. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_models.py +0 -0
  146. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_ollama_provider.py +0 -0
  147. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_openai_context_fit.py +0 -0
  148. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_openai_provider_tools.py +0 -0
  149. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_permission_rules.py +0 -0
  150. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_program_executor.py +0 -0
  151. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_provider_base.py +0 -0
  152. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_provider_integration.py +0 -0
  153. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_provider_registry.py +0 -0
  154. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_retry.py +0 -0
  155. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_security_validation.py +0 -0
  156. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_system_integration.py +0 -0
  157. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_tool_calling_integration.py +0 -0
  158. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_tool_handler.py +0 -0
  159. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/tests/test_workflows_comprehensive.py +0 -0
  160. {omnimancer_cli-0.2.10 → omnimancer_cli-0.2.15}/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.15
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,57 @@ 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
+
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
+
237
278
  async def run(self, prompt: str) -> int:
238
279
  agent_engine = getattr(self._engine, "agent_engine", None)
239
280
  if not agent_engine:
240
281
  self._emitter.emit_error("Agent engine not available.")
241
282
  return 1
242
283
 
284
+ if self._no_approval:
285
+ self._enable_auto_approval(agent_engine)
286
+ self._enable_full_trust(agent_engine)
287
+
243
288
  model = self._engine.config_manager.get_config().default_provider or "unknown"
244
289
  self._emitter.emit_init(model)
245
290
 
@@ -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)
@@ -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:
@@ -375,13 +383,22 @@ class FileSystemManager:
375
383
  path: Union[str, Path],
376
384
  encoding: str = "utf-8",
377
385
  binary: bool = False,
386
+ allow_sensitive: bool = False,
378
387
  ) -> Union[str, bytes]:
379
- """Read file content with security validation and error handling."""
388
+ """Read file content with security validation and error handling.
389
+
390
+ ``allow_sensitive`` permits reading sensitive project-local files (e.g.
391
+ ``.env``). It is set for internal reads that are part of an already
392
+ approved operation — such as the read-before-write diff/backup step —
393
+ so the existing content can be shown and preserved.
394
+ """
380
395
 
381
396
  path = Path(path).resolve()
382
397
 
383
398
  # Security validation
384
- result = await self.security.secure_file_access(str(path), "read")
399
+ result = await self.security.secure_file_access(
400
+ str(path), "read", allow_sensitive=allow_sensitive
401
+ )
385
402
  if not result["success"]:
386
403
  raise FileOperationError(f"Security check failed: {result['error']}")
387
404
 
@@ -1284,7 +1301,11 @@ class FileSystemManager:
1284
1301
 
1285
1302
  if file_exists:
1286
1303
  try:
1287
- current_content = await self.read_file(path, encoding=encoding)
1304
+ # This read is part of an approved write; allow sensitive
1305
+ # files (e.g. .env) so the diff and backup still work.
1306
+ current_content = await self.read_file(
1307
+ path, encoding=encoding, allow_sensitive=True
1308
+ )
1288
1309
  except Exception as e:
1289
1310
  # Handle file read error
1290
1311
  read_error = FileReadError(str(path), e)
@@ -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