bone-agent 1.4.0 → 2.0.0

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 (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
@@ -1,145 +0,0 @@
1
- """Tool auto-discovery and loading mechanism.
2
-
3
- This module provides automatic discovery and loading of tools from
4
- multiple directories. Tools are imported to trigger @tool decorator
5
- registration.
6
- """
7
-
8
- import importlib.util
9
- import logging
10
- import sys
11
- from pathlib import Path
12
- from typing import List, Optional
13
-
14
- from .base import ToolRegistry
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- def _is_python_file(path: Path) -> bool:
20
- """Check if a file is a Python module.
21
-
22
- Args:
23
- path: File path to check
24
-
25
- Returns:
26
- True if file is a .py file (not __pycache__ or test file)
27
- """
28
- return (
29
- path.suffix == ".py"
30
- and path.name != "__init__.py"
31
- and not path.name.startswith("test_")
32
- and not path.name.startswith("_")
33
- )
34
-
35
-
36
- def _load_module_from_path(module_name: str, file_path: Path) -> Optional[object]:
37
- """Load a Python module from a file path.
38
-
39
- Args:
40
- module_name: Name to give the module
41
- file_path: Path to the Python file
42
-
43
- Returns:
44
- Loaded module or None if loading failed
45
- """
46
- try:
47
- spec = importlib.util.spec_from_file_location(module_name, file_path)
48
- if spec is None or spec.loader is None:
49
- logger.warning(f"Could not load spec for {file_path}")
50
- return None
51
-
52
- module = importlib.util.module_from_spec(spec)
53
-
54
- # For external tool modules (not in src/tools/), set package to None
55
- # to force absolute imports instead of relative imports
56
- from tools import __file__ as tools_init_file
57
- tools_dir = Path(tools_init_file).parent
58
-
59
- # User tools are those not in the main tools directory
60
- # (helper modules are in src/tools/helpers/)
61
- if file_path.parent != tools_dir and file_path.parent != tools_dir / "helpers":
62
- # User tool - set to None to force absolute imports
63
- module.__package__ = None
64
-
65
- sys.modules[module_name] = module
66
- spec.loader.exec_module(module)
67
-
68
- logger.debug(f"Successfully loaded module: {module_name}")
69
- return module
70
-
71
- except Exception as e:
72
- logger.warning(f"Failed to load module {module_name} from {file_path}: {e}")
73
- return None
74
-
75
-
76
- def discover_tools(directories: List[str]) -> int:
77
- """Discover and load tools from specified directories.
78
-
79
- This scans directories for Python files and imports them,
80
- which triggers @tool decorator registration.
81
-
82
- Args:
83
- directories: List of directory paths to scan
84
-
85
- Returns:
86
- Number of tools successfully loaded
87
-
88
- Note:
89
- - Only .py files are considered (excluding __pycache__, tests)
90
- - Import errors are logged but don't stop discovery
91
- - User tools can override built-in tools (with warning)
92
- """
93
- initial_count = ToolRegistry.tool_count()
94
- loaded_count = 0
95
-
96
- for directory in directories:
97
- dir_path = Path(directory)
98
-
99
- if not dir_path.exists():
100
- logger.debug(f"Tool directory does not exist: {directory}")
101
- continue
102
-
103
- if not dir_path.is_dir():
104
- logger.warning(f"Tool path is not a directory: {directory}")
105
- continue
106
-
107
- logger.info(f"Discovering tools in: {directory}")
108
-
109
- # Find all Python files
110
- python_files = [f for f in dir_path.iterdir() if _is_python_file(f)]
111
-
112
- for py_file in python_files:
113
- # Create unique module name
114
- module_name = f"tools_{py_file.stem}_{hash(str(py_file)) & 0xFFFFFFFF}"
115
-
116
- # Skip modules already loaded (e.g. cron re-calling discover_tools)
117
- if module_name in sys.modules:
118
- logger.debug(f"Module already loaded, skipping: {module_name}")
119
- continue
120
-
121
- module = _load_module_from_path(module_name, py_file)
122
- if module:
123
- loaded_count += 1
124
-
125
- final_count = ToolRegistry.tool_count()
126
- new_tools = final_count - initial_count
127
-
128
- logger.info(
129
- f"Tool discovery complete: Loaded {loaded_count} modules, "
130
- f"registered {new_tools} new tools (total: {final_count})"
131
- )
132
-
133
- return new_tools
134
-
135
-
136
- def list_registered_tools() -> List[str]:
137
- """List names of all registered tools.
138
-
139
- Returns:
140
- List of tool names
141
- """
142
- return [tool.name for tool in ToolRegistry.get_all()]
143
-
144
-
145
-
@@ -1,231 +0,0 @@
1
- """Concurrent tool execution engine.
2
-
3
- This module provides parallel execution of multiple tool calls using
4
- ThreadPoolExecutor for I/O-bound operations like file reads and web searches.
5
- """
6
-
7
- import concurrent.futures
8
- from typing import List, Tuple
9
- from dataclasses import dataclass
10
-
11
- from .base import ToolRegistry, build_context
12
-
13
-
14
- @dataclass
15
- class ToolCall:
16
- """Represents a single tool call.
17
-
18
- Attributes:
19
- tool_id: Unique identifier for this tool call
20
- function_name: Name of the tool function to execute
21
- arguments: Dictionary of arguments to pass to the tool handler
22
- call_index: Index in original tool_calls array (for order preservation)
23
- """
24
- tool_id: str
25
- function_name: str
26
- arguments: dict
27
- call_index: int
28
-
29
-
30
- @dataclass
31
- class ToolResult:
32
- """Result of a tool execution.
33
-
34
- Attributes:
35
- tool_id: Unique identifier for the tool call
36
- call_index: Index in original tool_calls array (for order preservation)
37
- success: Whether the tool executed successfully
38
- result: String result from tool execution (if successful)
39
- error: Error message (if failed)
40
- should_exit: Whether the tool requested the orchestration loop to exit
41
- requires_approval: Whether this tool requires user approval (for orchestrator)
42
- """
43
- tool_id: str
44
- call_index: int
45
- success: bool
46
- result: str
47
- error: str = None
48
- should_exit: bool = False
49
- requires_approval: bool = False
50
-
51
-
52
- class ParallelToolExecutor:
53
- """Executes multiple tool calls concurrently with proper error handling.
54
-
55
- This class provides thread-safe concurrent execution of tool calls using
56
- ThreadPoolExecutor. Key features:
57
- - Executes independent tools concurrently for performance
58
- - Preserves result order using call_index tracking
59
- - Isolates errors (one failure doesn't stop others)
60
- - Fast-path optimization for single tool calls (no threading overhead)
61
- """
62
-
63
- def __init__(self, max_workers: int = 5):
64
- """Initialize executor.
65
-
66
- Args:
67
- max_workers: Maximum number of concurrent tool executions
68
- """
69
- self.max_workers = max_workers
70
-
71
- def execute_tools(
72
- self,
73
- tool_calls: List[ToolCall],
74
- context: dict
75
- ) -> Tuple[List[ToolResult], bool]:
76
- """Execute multiple tools concurrently.
77
-
78
- Args:
79
- tool_calls: List of ToolCall objects
80
- context: Dictionary containing repo_root, console, chat_manager, etc.
81
-
82
- Returns:
83
- Tuple of (results in call_index order, had_any_errors)
84
- """
85
- if len(tool_calls) == 1:
86
- # Fast path for single tool (no threading overhead)
87
- return self._execute_single(tool_calls[0], context)
88
-
89
- # Parallel execution for multiple tools
90
- results = []
91
-
92
- with concurrent.futures.ThreadPoolExecutor(
93
- max_workers=min(self.max_workers, len(tool_calls))
94
- ) as executor:
95
- # Submit all tool executions
96
- future_to_call = {
97
- executor.submit(
98
- self._execute_single_tool,
99
- tool_call,
100
- context
101
- ): tool_call
102
- for tool_call in tool_calls
103
- }
104
-
105
- # Collect results as they complete
106
- for future in concurrent.futures.as_completed(future_to_call):
107
- tool_call = future_to_call[future]
108
- try:
109
- result = future.result()
110
- results.append(result)
111
- except Exception as e:
112
- results.append(ToolResult(
113
- tool_id=tool_call.tool_id,
114
- call_index=tool_call.call_index,
115
- success=False,
116
- result="",
117
- error=str(e)
118
- ))
119
-
120
- # Sort by call_index to maintain order
121
- results.sort(key=lambda r: r.call_index)
122
-
123
- # Check for errors
124
- had_errors = any(not r.success for r in results)
125
-
126
- return results, had_errors
127
-
128
- def _execute_single(
129
- self,
130
- tool_call: ToolCall,
131
- context: dict
132
- ) -> Tuple[List[ToolResult], bool]:
133
- """Execute single tool (fast path, no threading overhead).
134
-
135
- Args:
136
- tool_call: Single ToolCall to execute
137
- context: Execution context dict
138
-
139
- Returns:
140
- Tuple of (single-element result list, had_errors)
141
- """
142
- result = self._execute_single_tool(tool_call, context)
143
- return [result], not result.success
144
-
145
- def _execute_single_tool(
146
- self,
147
- tool_call: ToolCall,
148
- context: dict
149
- ) -> ToolResult:
150
- """Execute a single tool call with error handling.
151
-
152
- Args:
153
- tool_call: ToolCall to execute
154
- context: Execution context dict
155
-
156
- Returns:
157
- ToolResult with execution outcome
158
- """
159
- tool = ToolRegistry.get(tool_call.function_name)
160
- if tool:
161
- try:
162
- # For tools requiring approval, return preview without executing
163
- # The orchestrator will handle the approval workflow
164
- if tool.requires_approval:
165
- # Build context from context dict
166
- cm = context.get('chat_manager')
167
-
168
- tool_context = build_context(
169
- repo_root=context.get('repo_root'),
170
- console=context.get('console'),
171
- gitignore_spec=context.get('gitignore_spec'),
172
- debug_mode=context.get('debug_mode', False),
173
- chat_manager=cm,
174
- rg_exe_path=context.get('rg_exe_path'),
175
- panel_updater=context.get('panel_updater'),
176
- vault_root=context.get('vault_root')
177
- )
178
-
179
- # For edit_file: return preview (orchestrator handles approval)
180
- if tool_call.function_name == "edit_file":
181
- tool_result = tool.execute(tool_call.arguments, tool_context)
182
- return ToolResult(
183
- tool_id=tool_call.tool_id,
184
- call_index=tool_call.call_index,
185
- success=True,
186
- result=tool_result,
187
- should_exit=False,
188
- requires_approval=True # Flag for orchestrator to handle
189
- )
190
- # Other approval-required tools would go here in the future
191
-
192
- # Normal execution for tools without approval
193
- # Build context from context dict
194
- cm = context.get('chat_manager')
195
-
196
- tool_context = build_context(
197
- repo_root=context.get('repo_root'),
198
- console=context.get('console'),
199
- gitignore_spec=context.get('gitignore_spec'),
200
- debug_mode=context.get('debug_mode', False),
201
- chat_manager=cm,
202
- rg_exe_path=context.get('rg_exe_path'),
203
- panel_updater=context.get('panel_updater'),
204
- vault_root=context.get('vault_root')
205
- )
206
-
207
- tool_result = tool.execute(tool_call.arguments, tool_context)
208
- return ToolResult(
209
- tool_id=tool_call.tool_id,
210
- call_index=tool_call.call_index,
211
- success=True,
212
- result=tool_result,
213
- should_exit=False
214
- )
215
- except Exception as e:
216
- return ToolResult(
217
- tool_id=tool_call.tool_id,
218
- call_index=tool_call.call_index,
219
- success=False,
220
- result="",
221
- error=f"Error executing tool '{tool_call.function_name}': {str(e)}"
222
- )
223
-
224
- # Tool not found in registry
225
- return ToolResult(
226
- tool_id=tool_call.tool_id,
227
- call_index=tool_call.call_index,
228
- success=False,
229
- result="",
230
- error=f"Unknown tool '{tool_call.function_name}'"
231
- )
@@ -1,283 +0,0 @@
1
- """Path resolution and validation utilities.
2
-
3
- This module provides centralized path validation and resolution logic,
4
- ensuring consistent behavior across all tools that work with file paths.
5
- """
6
-
7
- import os
8
- import time
9
- from pathlib import Path
10
- from typing import Optional, Tuple
11
-
12
- from .file_helpers import _is_reserved_windows_name
13
-
14
- # Performance metrics tracking
15
- _path_resolution_times = []
16
- _path_validation_errors = {}
17
-
18
- # Session-scoped filesystem access flag
19
- # When True, boundary enforcement is skipped — agent can access any path.
20
- _full_filesystem_access = False
21
-
22
- # Boundary error prefixes — used by both resolve_and_validate() and is_boundary_error().
23
- _BOUNDARY_ERROR_PREFIXES = (
24
- "Path is outside allowed directories:",
25
- "Path is outside repository:",
26
- )
27
-
28
-
29
- def has_full_filesystem_access() -> bool:
30
- """Check if full filesystem access has been granted this session."""
31
- return _full_filesystem_access
32
-
33
-
34
- def set_full_filesystem_access(enabled: bool):
35
- """Grant or revoke full filesystem access for this session."""
36
- global _full_filesystem_access
37
- _full_filesystem_access = enabled
38
-
39
-
40
- def _boundary_error_line(result: str) -> Optional[tuple[str, str]]:
41
- """Return the boundary prefix and message line from a tool result."""
42
- if not result:
43
- return None
44
-
45
- for line in result.splitlines():
46
- stripped = line.strip()
47
- if stripped.lower().startswith("error:"):
48
- stripped = stripped[len("error:"):].strip()
49
- for prefix in _BOUNDARY_ERROR_PREFIXES:
50
- if stripped.startswith(prefix):
51
- return prefix, stripped
52
- return None
53
-
54
-
55
- def is_boundary_error(result: str) -> bool:
56
- """Check if a tool result is a path boundary violation."""
57
- return _boundary_error_line(result) is not None
58
-
59
-
60
- def extract_boundary_path(result: str) -> str:
61
- """Extract the offending path from a boundary error message."""
62
- boundary_line = _boundary_error_line(result)
63
- if not boundary_line:
64
- return ""
65
-
66
- prefix, line = boundary_line
67
- return line[len(prefix):].strip()
68
-
69
-
70
- class PathResolver:
71
- """Centralized path resolution and validation.
72
-
73
- This class provides a single source of truth for path validation logic,
74
- including Windows-specific checks, path resolution, and gitignore filtering.
75
-
76
- Example:
77
- resolver = PathResolver(repo_root=Path("/project"), gitignore_spec=spec)
78
- resolved_path, error = resolver.resolve_and_validate("src/file.py")
79
- if error:
80
- return f"Error: {error}"
81
- # Use resolved_path...
82
- """
83
-
84
- def __init__(
85
- self,
86
- repo_root: Path,
87
- gitignore_spec = None,
88
- vault_path: Path = None
89
- ):
90
- """Initialize the path resolver.
91
-
92
- Args:
93
- repo_root: Repository root directory for resolving relative paths
94
- gitignore_spec: Optional PathSpec for .gitignore filtering
95
- vault_path: Optional Obsidian vault root (allowed as second base path)
96
- """
97
- self.repo_root = repo_root
98
- self.vault_path = vault_path
99
- self.gitignore_spec = gitignore_spec
100
-
101
- def resolve_and_validate(
102
- self,
103
- path_str: str,
104
- check_gitignore: bool = True,
105
- must_exist: bool = True,
106
- must_be_file: bool = False,
107
- must_be_dir: bool = False,
108
- enforce_boundary: bool = False,
109
- ) -> Tuple[Optional[Path], Optional[str]]:
110
- """Validate and resolve a path string.
111
-
112
- Performs comprehensive validation including:
113
- - Windows filename validation (invalid chars, reserved names)
114
- - Path resolution (absolute vs relative)
115
- - Gitignore filtering (optional, within repo only)
116
- - Path existence check (optional)
117
- - Type validation (file/directory, optional)
118
-
119
- Args:
120
- path_str: Path string to validate
121
- check_gitignore: Whether to apply gitignore filtering
122
- must_exist: Whether the path must exist on disk
123
- must_be_file: Whether the path must be a file (requires must_exist=True)
124
- must_be_dir: Whether the path must be a directory (requires must_exist=True)
125
- enforce_boundary: Whether to restrict paths to repo_root or vault_path (default: False)
126
-
127
- Returns:
128
- Tuple of (resolved_path, error_message)
129
- - resolved_path: Path object if valid, None if invalid
130
- - error_message: None if valid, error description if invalid
131
- """
132
- start_time = time.time()
133
-
134
- try:
135
- # Step 1: Validate filename for Windows-specific issues
136
- if os.name == 'nt': # Windows-specific validation
137
- # Check for invalid characters
138
- invalid_chars = '<>:"|?*[]{}"\n\r\t'
139
- if any(char in path_str for char in invalid_chars):
140
- elapsed = time.time() - start_time
141
- _track_validation_error("invalid_chars")
142
- _path_resolution_times.append(elapsed)
143
- return None, f"Filename contains invalid characters: {invalid_chars}"
144
-
145
- # Check for reserved device names
146
- filename = Path(path_str).name
147
- if _is_reserved_windows_name(filename):
148
- elapsed = time.time() - start_time
149
- _track_validation_error("reserved_name")
150
- _path_resolution_times.append(elapsed)
151
- return None, f"Filename is a reserved Windows device name: {filename}"
152
-
153
- # Step 2: Resolve the path
154
- path = Path(path_str)
155
- if not path.is_absolute():
156
- path = self.repo_root / path
157
-
158
- # Resolve to absolute path (handles .. and symlinks)
159
- path = path.resolve()
160
-
161
- # Step 2b: Security boundary — path must be within repo_root, vault_path,
162
- # or the agent's own data directory (~/.bone/).
163
- if enforce_boundary and not _full_filesystem_access:
164
- try:
165
- path.relative_to(self.repo_root)
166
- except ValueError:
167
- # Check ~/.bone/ — agent data dir is always accessible
168
- bone_root = Path.home() / ".bone"
169
- try:
170
- path.relative_to(bone_root)
171
- except ValueError:
172
- if self.vault_path is not None:
173
- try:
174
- path.relative_to(self.vault_path)
175
- except ValueError:
176
- elapsed = time.time() - start_time
177
- _track_validation_error("outside_allowed_roots")
178
- _path_resolution_times.append(elapsed)
179
- return None, f"{_BOUNDARY_ERROR_PREFIXES[0]} {path_str}"
180
- else:
181
- elapsed = time.time() - start_time
182
- _track_validation_error("outside_repo")
183
- _path_resolution_times.append(elapsed)
184
- return None, f"{_BOUNDARY_ERROR_PREFIXES[1]} {path_str}"
185
-
186
- # Step 3: Check existence if required
187
- if must_exist:
188
- if not path.exists():
189
- elapsed = time.time() - start_time
190
- _track_validation_error("not_found")
191
- _path_resolution_times.append(elapsed)
192
- return None, f"Path not found: {path_str}"
193
-
194
- # Step 4: Validate type if required
195
- if must_be_file and not path.is_file():
196
- elapsed = time.time() - start_time
197
- _track_validation_error("not_a_file")
198
- _path_resolution_times.append(elapsed)
199
- return None, f"Path is not a file: {path_str}"
200
-
201
- if must_be_dir and not path.is_dir():
202
- elapsed = time.time() - start_time
203
- _track_validation_error("not_a_dir")
204
- _path_resolution_times.append(elapsed)
205
- return None, f"Path is not a directory: {path_str}"
206
-
207
- # Step 5: Check gitignore if requested and within repo
208
- if check_gitignore and self.gitignore_spec is not None:
209
- # Only check gitignore for paths within the repo
210
- try:
211
- path.relative_to(self.repo_root)
212
- from .file_helpers import _is_ignored_cached, _register_gitignore_spec
213
-
214
- # Full gitignore check
215
- spec_key = _register_gitignore_spec(self.gitignore_spec)
216
- if _is_ignored_cached(str(path), str(self.repo_root), spec_key):
217
- elapsed = time.time() - start_time
218
- _track_validation_error("gitignore_filtered")
219
- _path_resolution_times.append(elapsed)
220
- return None, f"Path is excluded by .gitignore: {path_str}"
221
- except ValueError:
222
- # Path is outside repo, skip gitignore check
223
- pass
224
-
225
- # Success - track timing
226
- elapsed = time.time() - start_time
227
- _path_resolution_times.append(elapsed)
228
- return path, None
229
-
230
- except OSError as e:
231
- elapsed = time.time() - start_time
232
- _track_validation_error("os_error")
233
- _path_resolution_times.append(elapsed)
234
- return None, f"Error accessing path '{path_str}': {e}"
235
- except Exception as e:
236
- elapsed = time.time() - start_time
237
- _track_validation_error("unexpected_error")
238
- _path_resolution_times.append(elapsed)
239
- return None, f"Unexpected error resolving path '{path_str}': {e}"
240
-
241
-
242
- def _track_validation_error(error_type: str):
243
- """Track validation errors for metrics.
244
-
245
- Args:
246
- error_type: Type of validation error
247
- """
248
- _path_validation_errors[error_type] = _path_validation_errors.get(error_type, 0) + 1
249
-
250
-
251
- def get_path_resolver_metrics() -> dict:
252
- """Get performance metrics for path resolution operations.
253
-
254
- Returns:
255
- Dictionary with metrics:
256
- - total_resolutions: Total number of path resolutions
257
- - avg_resolution_time: Average resolution time in seconds
258
- - max_resolution_time: Maximum resolution time
259
- - min_resolution_time: Minimum resolution time
260
- - validation_errors: Dict of error types and counts
261
- """
262
- if not _path_resolution_times:
263
- return {
264
- "total_resolutions": 0,
265
- "avg_resolution_time": 0,
266
- "max_resolution_time": 0,
267
- "min_resolution_time": 0,
268
- "validation_errors": _path_validation_errors.copy()
269
- }
270
-
271
- return {
272
- "total_resolutions": len(_path_resolution_times),
273
- "avg_resolution_time": sum(_path_resolution_times) / len(_path_resolution_times),
274
- "max_resolution_time": max(_path_resolution_times),
275
- "min_resolution_time": min(_path_resolution_times),
276
- "validation_errors": _path_validation_errors.copy()
277
- }
278
-
279
-
280
- def clear_path_resolver_metrics():
281
- """Clear all accumulated metrics for testing or monitoring reset."""
282
- _path_resolution_times.clear()
283
- _path_validation_errors.clear()