vibecore 0.3.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

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.

Potentially problematic release.


This version of vibecore might be problematic. Click here for more details.

vibecore/context.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from dataclasses import dataclass, field
2
+ from pathlib import Path
2
3
  from typing import TYPE_CHECKING, Optional
3
4
 
4
5
  from vibecore.tools.python.manager import PythonExecutionManager
@@ -7,6 +8,7 @@ from vibecore.tools.todo.manager import TodoManager
7
8
  if TYPE_CHECKING:
8
9
  from vibecore.main import VibecoreApp
9
10
  from vibecore.mcp import MCPManager
11
+ from vibecore.tools.path_validator import PathValidator
10
12
 
11
13
 
12
14
  @dataclass
@@ -17,8 +19,43 @@ class VibecoreContext:
17
19
  context_fullness: float = 0.0
18
20
  mcp_manager: Optional["MCPManager"] = None
19
21
 
22
+ # Path confinement configuration
23
+ allowed_directories: list[Path] = field(default_factory=list)
24
+ path_validator: "PathValidator" = field(init=False) # Always initialized, never None
25
+
26
+ def __post_init__(self):
27
+ """Initialize path validator with allowed directories."""
28
+ from vibecore.tools.path_validator import PathValidator
29
+
30
+ if not self.allowed_directories:
31
+ # Load from settings if not explicitly provided
32
+ from vibecore.settings import settings
33
+
34
+ if settings.path_confinement.enabled:
35
+ self.allowed_directories = settings.path_confinement.allowed_directories
36
+ # Add home directory if configured
37
+ if settings.path_confinement.allow_home:
38
+ self.allowed_directories.append(Path.home())
39
+ # Add temp directories if configured
40
+ if settings.path_confinement.allow_temp:
41
+ import tempfile
42
+
43
+ temp_dir = Path(tempfile.gettempdir())
44
+ if temp_dir not in self.allowed_directories:
45
+ self.allowed_directories.append(temp_dir)
46
+ else:
47
+ # If path confinement is disabled, allow CWD only (but validator won't be used)
48
+ self.allowed_directories = [Path.cwd()]
49
+
50
+ self.path_validator = PathValidator(self.allowed_directories)
51
+
20
52
  def reset_state(self) -> None:
21
53
  """Reset all context state for a new session."""
22
54
  self.todo_manager = TodoManager()
23
55
  self.python_manager = PythonExecutionManager()
24
56
  self.context_fullness = 0.0
57
+ # Preserve allowed_directories across resets
58
+ # Re-initialize validator in case directories changed
59
+ from vibecore.tools.path_validator import PathValidator
60
+
61
+ self.path_validator = PathValidator(self.allowed_directories)
vibecore/settings.py CHANGED
@@ -39,6 +39,48 @@ class SessionSettings(BaseModel):
39
39
  )
40
40
 
41
41
 
42
+ class PathConfinementSettings(BaseModel):
43
+ """Configuration for path confinement."""
44
+
45
+ enabled: bool = Field(
46
+ default=True,
47
+ description="Enable path confinement for file and shell tools",
48
+ )
49
+
50
+ allowed_directories: list[Path] = Field(
51
+ default_factory=lambda: [Path.cwd()],
52
+ description="List of directories that tools can access",
53
+ )
54
+
55
+ allow_home: bool = Field(
56
+ default=False,
57
+ description="Allow access to user's home directory",
58
+ )
59
+
60
+ allow_temp: bool = Field(
61
+ default=True,
62
+ description="Allow access to system temp directories",
63
+ )
64
+
65
+ strict_mode: bool = Field(
66
+ default=False,
67
+ description="Strict mode prevents any path traversal attempts",
68
+ )
69
+
70
+ @field_validator("allowed_directories", mode="before")
71
+ @classmethod
72
+ def resolve_paths(cls, v: list[str | Path]) -> list[Path]:
73
+ """Resolve and validate directory paths."""
74
+ paths = []
75
+ for p in v:
76
+ path = Path(p).expanduser().resolve()
77
+ if not path.exists():
78
+ # Create directory if it doesn't exist
79
+ path.mkdir(parents=True, exist_ok=True)
80
+ paths.append(path)
81
+ return paths
82
+
83
+
42
84
  class MCPServerConfig(BaseModel):
43
85
  """Configuration for an MCP server."""
44
86
 
@@ -155,6 +197,12 @@ class Settings(BaseSettings):
155
197
  description="List of MCP servers to connect to",
156
198
  )
157
199
 
200
+ # Path confinement configuration
201
+ path_confinement: PathConfinementSettings = Field(
202
+ default_factory=PathConfinementSettings,
203
+ description="Path confinement configuration",
204
+ )
205
+
158
206
  rich_tool_names: list[str] = Field(
159
207
  default_factory=list,
160
208
  description="List of tools to render with RichToolMessage (temporary settings)",
@@ -207,7 +255,6 @@ class Settings(BaseSettings):
207
255
  return (
208
256
  init_settings,
209
257
  env_settings,
210
- dotenv_settings,
211
258
  YamlConfigSettingsSource(settings_cls),
212
259
  file_secret_settings,
213
260
  )
@@ -2,13 +2,22 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- from .utils import PathValidationError, format_line_with_number, validate_file_path
5
+ from agents import RunContextWrapper
6
6
 
7
+ from vibecore.context import VibecoreContext
8
+ from vibecore.settings import settings
9
+ from vibecore.tools.file.utils import PathValidationError
7
10
 
8
- async def read_file(file_path: str, offset: int | None = None, limit: int | None = None) -> str:
11
+ from .utils import format_line_with_number
12
+
13
+
14
+ async def read_file(
15
+ ctx: RunContextWrapper[VibecoreContext], file_path: str, offset: int | None = None, limit: int | None = None
16
+ ) -> str:
9
17
  """Read a file and return its contents in cat -n format.
10
18
 
11
19
  Args:
20
+ ctx: The context wrapper containing the VibecoreContext
12
21
  file_path: The path to the file to read
13
22
  offset: The line number to start reading from (1-based)
14
23
  limit: The maximum number of lines to read
@@ -17,8 +26,14 @@ async def read_file(file_path: str, offset: int | None = None, limit: int | None
17
26
  The file contents with line numbers, or an error message
18
27
  """
19
28
  try:
20
- # Validate the file path
21
- validated_path = validate_file_path(file_path)
29
+ # Validate the file path using context if path confinement is enabled
30
+ if settings.path_confinement.enabled:
31
+ validated_path = ctx.context.path_validator.validate_path(file_path, operation="read")
32
+ else:
33
+ # Fall back to simple validation against CWD
34
+ from .utils import validate_file_path
35
+
36
+ validated_path = validate_file_path(file_path)
22
37
 
23
38
  # Check if file exists
24
39
  if not validated_path.exists():
@@ -84,10 +99,13 @@ async def read_file(file_path: str, offset: int | None = None, limit: int | None
84
99
  return f"Error: Unexpected error reading file: {e}"
85
100
 
86
101
 
87
- async def edit_file(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
102
+ async def edit_file(
103
+ ctx: RunContextWrapper[VibecoreContext], file_path: str, old_string: str, new_string: str, replace_all: bool = False
104
+ ) -> str:
88
105
  """Edit a file by replacing strings.
89
106
 
90
107
  Args:
108
+ ctx: The context wrapper containing the VibecoreContext
91
109
  file_path: The path to the file to edit
92
110
  old_string: The text to replace
93
111
  new_string: The text to replace it with
@@ -97,8 +115,14 @@ async def edit_file(file_path: str, old_string: str, new_string: str, replace_al
97
115
  Success message or error message
98
116
  """
99
117
  try:
100
- # Validate the file path
101
- validated_path = validate_file_path(file_path)
118
+ # Validate the file path using context if path confinement is enabled
119
+ if settings.path_confinement.enabled:
120
+ validated_path = ctx.context.path_validator.validate_path(file_path, operation="edit")
121
+ else:
122
+ # Fall back to simple validation against CWD
123
+ from .utils import validate_file_path
124
+
125
+ validated_path = validate_file_path(file_path)
102
126
 
103
127
  # Check if file exists
104
128
  if not validated_path.exists():
@@ -158,10 +182,11 @@ async def edit_file(file_path: str, old_string: str, new_string: str, replace_al
158
182
  return f"Error: Unexpected error editing file: {e}"
159
183
 
160
184
 
161
- async def multi_edit_file(file_path: str, edits: list[dict[str, Any]]) -> str:
185
+ async def multi_edit_file(ctx: RunContextWrapper[VibecoreContext], file_path: str, edits: list[dict[str, Any]]) -> str:
162
186
  """Edit a file by applying multiple replacements sequentially.
163
187
 
164
188
  Args:
189
+ ctx: The context wrapper containing the VibecoreContext
165
190
  file_path: The path to the file to edit
166
191
  edits: List of edit operations, each containing old_string, new_string, and optional replace_all
167
192
 
@@ -169,8 +194,14 @@ async def multi_edit_file(file_path: str, edits: list[dict[str, Any]]) -> str:
169
194
  Success message or error message
170
195
  """
171
196
  try:
172
- # Validate the file path
173
- validated_path = validate_file_path(file_path)
197
+ # Validate the file path using context if path confinement is enabled
198
+ if settings.path_confinement.enabled:
199
+ validated_path = ctx.context.path_validator.validate_path(file_path, operation="multi_edit")
200
+ else:
201
+ # Fall back to simple validation against CWD
202
+ from .utils import validate_file_path
203
+
204
+ validated_path = validate_file_path(file_path)
174
205
 
175
206
  # Check if file exists
176
207
  if not validated_path.exists():
@@ -239,10 +270,11 @@ async def multi_edit_file(file_path: str, edits: list[dict[str, Any]]) -> str:
239
270
  return f"Error: Unexpected error editing file: {e}"
240
271
 
241
272
 
242
- async def write_file(file_path: str, content: str) -> str:
273
+ async def write_file(ctx: RunContextWrapper[VibecoreContext], file_path: str, content: str) -> str:
243
274
  """Write content to a file.
244
275
 
245
276
  Args:
277
+ ctx: The context wrapper containing the VibecoreContext
246
278
  file_path: The path to the file to write
247
279
  content: The content to write to the file
248
280
 
@@ -250,8 +282,14 @@ async def write_file(file_path: str, content: str) -> str:
250
282
  Success message or error message
251
283
  """
252
284
  try:
253
- # Validate the file path
254
- validated_path = validate_file_path(file_path)
285
+ # Validate the file path using context if path confinement is enabled
286
+ if settings.path_confinement.enabled:
287
+ validated_path = ctx.context.path_validator.validate_path(file_path, operation="write")
288
+ else:
289
+ # Fall back to simple validation against CWD
290
+ from .utils import validate_file_path
291
+
292
+ validated_path = validate_file_path(file_path)
255
293
 
256
294
  # Check if it's a directory
257
295
  if validated_path.exists() and validated_path.is_dir():
@@ -49,7 +49,7 @@ async def read(
49
49
  Returns:
50
50
  The file contents with line numbers in cat -n format, or an error message
51
51
  """
52
- return await read_file(file_path, offset, limit)
52
+ return await read_file(ctx, file_path, offset, limit)
53
53
 
54
54
 
55
55
  @function_tool
@@ -86,7 +86,7 @@ async def edit(
86
86
  Returns:
87
87
  Success message or error message
88
88
  """
89
- return await edit_file(file_path, old_string, new_string, replace_all)
89
+ return await edit_file(ctx, file_path, old_string, new_string, replace_all)
90
90
 
91
91
 
92
92
  @function_tool
@@ -153,7 +153,7 @@ async def multi_edit(
153
153
  """
154
154
  # Convert EditOperation objects to dictionaries
155
155
  edit_dicts = [edit.model_dump() for edit in edits]
156
- return await multi_edit_file(file_path, edit_dicts)
156
+ return await multi_edit_file(ctx, file_path, edit_dicts)
157
157
 
158
158
 
159
159
  @function_tool
@@ -181,4 +181,4 @@ async def write(
181
181
  Returns:
182
182
  Success message or error message
183
183
  """
184
- return await write_file(file_path, content)
184
+ return await write_file(ctx, file_path, content)
@@ -0,0 +1,251 @@
1
+ """Path validation module for vibecore tools.
2
+
3
+ This module provides path validation functionality to confine file and shell
4
+ operations to a configurable list of allowed directories.
5
+ """
6
+
7
+ import shlex
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+
11
+ from textual import log
12
+
13
+ from vibecore.tools.file.utils import PathValidationError
14
+
15
+
16
+ class PathValidator:
17
+ """Validates paths against a list of allowed directories."""
18
+
19
+ def __init__(self, allowed_directories: list[Path]):
20
+ """Initialize with list of allowed directories.
21
+
22
+ Args:
23
+ allowed_directories: List of directories to allow access to.
24
+ Defaults to [CWD] if empty.
25
+ """
26
+ self.allowed_directories = (
27
+ [d.resolve() for d in allowed_directories] if allowed_directories else [Path.cwd().resolve()]
28
+ )
29
+
30
+ def validate_path(self, path: str | Path, operation: str = "access") -> Path:
31
+ """Validate a path against allowed directories.
32
+
33
+ Args:
34
+ path: The path to validate
35
+ operation: Description of the operation (for error messages)
36
+
37
+ Returns:
38
+ The validated absolute Path object
39
+
40
+ Raises:
41
+ PathValidationError: If path is outside allowed directories
42
+ """
43
+ # Convert to Path object
44
+ path_obj = Path(path) if isinstance(path, str) else path
45
+
46
+ # Resolve to absolute path (follows symlinks)
47
+ try:
48
+ absolute_path = path_obj.resolve()
49
+ except (OSError, RuntimeError) as e:
50
+ # Handle cases where path resolution fails
51
+ raise PathValidationError(f"Cannot resolve path '{path}': {e}") from e
52
+
53
+ # Check if path is under any allowed directory
54
+ if not self.is_path_allowed(absolute_path):
55
+ allowed_dirs_str = ", ".join(f"'{d}'" for d in self.allowed_directories)
56
+ raise PathValidationError(
57
+ f"Path '{absolute_path}' is outside the allowed directories. "
58
+ f"Access is restricted to {allowed_dirs_str} and their subdirectories."
59
+ )
60
+
61
+ return absolute_path
62
+
63
+ def validate_command_paths(self, command: str) -> None:
64
+ """Validate paths referenced in a shell command.
65
+
66
+ Args:
67
+ command: The shell command to validate
68
+
69
+ Raises:
70
+ PathValidationError: If command references paths outside allowed directories
71
+ """
72
+ # Parse the command to extract potential file paths
73
+ try:
74
+ # First, replace shell operators with spaces around them to ensure proper splitting
75
+ # This handles cases like "cd /path;ls" which shlex doesn't split properly
76
+ for op in [";", "&&", "||", "|", "&"]:
77
+ command = command.replace(op, f" {op} ")
78
+
79
+ # Use shlex to properly parse the command
80
+ tokens = shlex.split(command)
81
+ except ValueError as e:
82
+ # If shlex fails, the command might be malformed
83
+ raise PathValidationError(f"Cannot parse command: {e}") from e
84
+
85
+ # Commands that take path arguments
86
+ path_commands = {
87
+ "cat",
88
+ "ls",
89
+ "cd",
90
+ "cp",
91
+ "mv",
92
+ "rm",
93
+ "mkdir",
94
+ "rmdir",
95
+ "touch",
96
+ "chmod",
97
+ "chown",
98
+ "head",
99
+ "tail",
100
+ "less",
101
+ "more",
102
+ "grep",
103
+ "find",
104
+ "sed",
105
+ "awk",
106
+ "wc",
107
+ "du",
108
+ "df",
109
+ "tar",
110
+ "zip",
111
+ "unzip",
112
+ "vim",
113
+ "vi",
114
+ "nano",
115
+ "emacs",
116
+ "code",
117
+ "open",
118
+ }
119
+
120
+ # Check each token that might be a path
121
+ current_command = None
122
+ piped_command = False # Track if command comes after a pipe
123
+ for i, token in enumerate(tokens):
124
+ # Skip shell operators
125
+ if token in ["&&", "||", ";", "|", "&", ">", ">>", "<", "2>", "&>"]:
126
+ if token == "|":
127
+ piped_command = True
128
+ elif token in ["&&", "||", ";"]:
129
+ piped_command = False
130
+ continue
131
+
132
+ # Skip flags and options
133
+ if token.startswith("-"):
134
+ continue
135
+
136
+ # Check if this is a command
137
+ if i == 0 or tokens[i - 1] in ["&&", "||", ";", "|"]:
138
+ current_command = token.split("/")[-1] # Get base command name
139
+ # Don't validate grep/awk/sed arguments after pipes - they're patterns not paths
140
+ if piped_command and current_command in ["grep", "awk", "sed", "sort", "uniq", "wc"]:
141
+ current_command = None
142
+ if tokens[i - 1] in ["&&", "||", ";"]:
143
+ piped_command = False
144
+ continue
145
+
146
+ # Check for redirections
147
+ if i > 0 and tokens[i - 1] in [">", ">>", "<", "2>", "&>"]:
148
+ # This is a file path for redirection
149
+ self._validate_path_token(token, f"redirect to/from '{token}'")
150
+ continue
151
+
152
+ # Check if current command takes path arguments
153
+ if current_command in path_commands:
154
+ # Skip if it looks like an option value
155
+ if i > 0 and tokens[i - 1].startswith("-"):
156
+ continue
157
+ # This might be a path argument
158
+ self._validate_path_token(token, f"access '{token}'")
159
+
160
+ # Check for paths in other contexts (if they look like paths)
161
+ elif "/" in token or token in [".", "..", "~"]:
162
+ # This looks like a path, validate it
163
+ with suppress(PathValidationError):
164
+ # It might not be a path, just a string with slash
165
+ # We'll be lenient here if it fails
166
+ self._validate_path_token(token, f"access '{token}'")
167
+
168
+ def _validate_path_token(self, token: str, operation: str) -> None:
169
+ """Validate a single path token from a command.
170
+
171
+ Args:
172
+ token: The token that might be a path
173
+ operation: Description of the operation
174
+
175
+ Raises:
176
+ PathValidationError: If the path is not allowed
177
+ """
178
+ # Expand user home directory
179
+ if token.startswith("~"):
180
+ token = str(Path(token).expanduser())
181
+
182
+ # Skip URLs and remote paths
183
+ if (
184
+ token.startswith("http://")
185
+ or token.startswith("https://")
186
+ or token.startswith("ftp://")
187
+ or token.startswith("ssh://")
188
+ or token.startswith("git@")
189
+ or ":" in token.split("/")[0]
190
+ ): # user@host:path
191
+ return
192
+
193
+ # Try to validate as a path
194
+ try:
195
+ path = Path(token)
196
+ # If it's a relative path, resolve it from CWD
197
+ if not path.is_absolute():
198
+ path = Path.cwd() / path
199
+ self.validate_path(path, operation)
200
+ except (ValueError, OSError):
201
+ # Not a valid path, skip validation
202
+ pass
203
+
204
+ def is_path_allowed(self, path: Path) -> bool:
205
+ """Check if a path is within allowed directories.
206
+
207
+ Args:
208
+ path: The path to check (should be absolute)
209
+
210
+ Returns:
211
+ True if path is allowed, False otherwise
212
+ """
213
+ # Ensure path is absolute
214
+ path = path.resolve()
215
+ log(f"Validating path: {path}")
216
+
217
+ # Check if path is under any allowed directory
218
+ for allowed_dir in self.allowed_directories:
219
+ try:
220
+ # Check if path is relative to allowed_dir
221
+ path.relative_to(allowed_dir)
222
+ return True
223
+ except ValueError:
224
+ # path is not relative to this allowed_dir
225
+ continue
226
+
227
+ return False
228
+
229
+ def _is_parent_of(self, parent: Path, child: Path) -> bool:
230
+ """Check if parent is a parent directory of child.
231
+
232
+ Args:
233
+ parent: Potential parent path
234
+ child: Potential child path
235
+
236
+ Returns:
237
+ True if parent is a parent of child
238
+ """
239
+ try:
240
+ child.relative_to(parent)
241
+ return True
242
+ except ValueError:
243
+ return False
244
+
245
+ def get_allowed_directories(self) -> list[Path]:
246
+ """Get the list of allowed directories.
247
+
248
+ Returns:
249
+ List of allowed directory paths
250
+ """
251
+ return self.allowed_directories.copy()
@@ -6,13 +6,20 @@ import re
6
6
  import subprocess
7
7
  from pathlib import Path
8
8
 
9
+ from agents import RunContextWrapper
10
+
11
+ from vibecore.context import VibecoreContext
12
+ from vibecore.settings import settings
9
13
  from vibecore.tools.file.utils import PathValidationError, validate_file_path
10
14
 
11
15
 
12
- async def bash_executor(command: str, timeout: int | None = None) -> tuple[str, int]:
16
+ async def bash_executor(
17
+ ctx: RunContextWrapper[VibecoreContext], command: str, timeout: int | None = None
18
+ ) -> tuple[str, int]:
13
19
  """Execute a bash command asynchronously.
14
20
 
15
21
  Args:
22
+ ctx: The context wrapper containing the VibecoreContext
16
23
  command: The bash command to execute
17
24
  timeout: Optional timeout in milliseconds (max 600000)
18
25
 
@@ -32,6 +39,13 @@ async def bash_executor(command: str, timeout: int | None = None) -> tuple[str,
32
39
  # Convert timeout to seconds
33
40
  timeout_seconds = timeout / 1000.0
34
41
 
42
+ # Validate command paths if path confinement is enabled
43
+ if settings.path_confinement.enabled:
44
+ try:
45
+ ctx.context.path_validator.validate_command_paths(command)
46
+ except PathValidationError as e:
47
+ return f"Error: {e}", 1
48
+
35
49
  process = None
36
50
  try:
37
51
  # Create subprocess
@@ -66,10 +80,11 @@ async def bash_executor(command: str, timeout: int | None = None) -> tuple[str,
66
80
  return f"Error executing command: {e}", 1
67
81
 
68
82
 
69
- async def glob_files(pattern: str, path: str | None = None) -> list[str]:
83
+ async def glob_files(ctx: RunContextWrapper[VibecoreContext], pattern: str, path: str | None = None) -> list[str]:
70
84
  """Find files matching a glob pattern.
71
85
 
72
86
  Args:
87
+ ctx: The context wrapper containing the VibecoreContext
73
88
  pattern: The glob pattern to match
74
89
  path: Optional directory to search in (defaults to CWD)
75
90
 
@@ -78,7 +93,23 @@ async def glob_files(pattern: str, path: str | None = None) -> list[str]:
78
93
  """
79
94
  try:
80
95
  # Validate and resolve the path
81
- search_path = Path.cwd() if path is None else validate_file_path(path)
96
+ if path is None:
97
+ search_path = Path.cwd()
98
+ # Validate CWD is in allowed directories if path confinement is enabled
99
+ if settings.path_confinement.enabled:
100
+ try:
101
+ ctx.context.path_validator.validate_path(search_path, operation="glob")
102
+ except PathValidationError as e:
103
+ return [f"Error: {e}"]
104
+ else:
105
+ # Validate the provided path
106
+ if settings.path_confinement.enabled:
107
+ try:
108
+ search_path = ctx.context.path_validator.validate_path(path, operation="glob")
109
+ except PathValidationError as e:
110
+ return [f"Error: {e}"]
111
+ else:
112
+ search_path = validate_file_path(path)
82
113
 
83
114
  # Validate path is a directory
84
115
  if not search_path.is_dir():
@@ -110,10 +141,13 @@ async def glob_files(pattern: str, path: str | None = None) -> list[str]:
110
141
  return [f"Error: {e}"]
111
142
 
112
143
 
113
- async def grep_files(pattern: str, path: str | None = None, include: str | None = None) -> list[str]:
144
+ async def grep_files(
145
+ ctx: RunContextWrapper[VibecoreContext], pattern: str, path: str | None = None, include: str | None = None
146
+ ) -> list[str]:
114
147
  """Search file contents using regular expressions.
115
148
 
116
149
  Args:
150
+ ctx: The context wrapper containing the VibecoreContext
117
151
  pattern: The regex pattern to search for
118
152
  path: Directory to search in (defaults to CWD)
119
153
  include: File pattern to include (e.g. "*.js")
@@ -123,7 +157,23 @@ async def grep_files(pattern: str, path: str | None = None, include: str | None
123
157
  """
124
158
  try:
125
159
  # Validate and resolve the path
126
- search_path = Path.cwd() if path is None else validate_file_path(path)
160
+ if path is None:
161
+ search_path = Path.cwd()
162
+ # Validate CWD is in allowed directories if path confinement is enabled
163
+ if settings.path_confinement.enabled:
164
+ try:
165
+ ctx.context.path_validator.validate_path(search_path, operation="grep")
166
+ except PathValidationError as e:
167
+ return [f"Error: {e}"]
168
+ else:
169
+ # Validate the provided path
170
+ if settings.path_confinement.enabled:
171
+ try:
172
+ search_path = ctx.context.path_validator.validate_path(path, operation="grep")
173
+ except PathValidationError as e:
174
+ return [f"Error: {e}"]
175
+ else:
176
+ search_path = validate_file_path(path)
127
177
 
128
178
  # Validate path is a directory
129
179
  if not search_path.is_dir():
@@ -175,10 +225,13 @@ async def grep_files(pattern: str, path: str | None = None, include: str | None
175
225
  return [f"Error: {e}"]
176
226
 
177
227
 
178
- async def list_directory(path: str, ignore: list[str] | None = None) -> list[str]:
228
+ async def list_directory(
229
+ ctx: RunContextWrapper[VibecoreContext], path: str, ignore: list[str] | None = None
230
+ ) -> list[str]:
179
231
  """List files and directories in a given path.
180
232
 
181
233
  Args:
234
+ ctx: The context wrapper containing the VibecoreContext
182
235
  path: The absolute path to list
183
236
  ignore: Optional list of glob patterns to ignore
184
237
 
@@ -187,7 +240,10 @@ async def list_directory(path: str, ignore: list[str] | None = None) -> list[str
187
240
  """
188
241
  try:
189
242
  # Validate and resolve the path
190
- dir_path = validate_file_path(path)
243
+ if settings.path_confinement.enabled:
244
+ dir_path = ctx.context.path_validator.validate_path(path, operation="list")
245
+ else:
246
+ dir_path = validate_file_path(path)
191
247
 
192
248
  # Validate path is a directory
193
249
  if not dir_path.is_dir():
@@ -58,7 +58,7 @@ async def bash(
58
58
  Returns:
59
59
  The command output or error message
60
60
  """
61
- output, exit_code = await bash_executor(command, timeout)
61
+ output, exit_code = await bash_executor(ctx, command, timeout)
62
62
  if exit_code != 0:
63
63
  return f"{output}\nExit code: {exit_code}"
64
64
  return output
@@ -90,7 +90,7 @@ async def glob(
90
90
  Returns:
91
91
  List of matching file paths, one per line
92
92
  """
93
- files = await glob_files(pattern, path)
93
+ files = await glob_files(ctx, pattern, path)
94
94
  if files and files[0].startswith("Error:"):
95
95
  return files[0]
96
96
  return "\n".join(files) if files else "No files found matching pattern"
@@ -124,7 +124,7 @@ async def grep(
124
124
  Returns:
125
125
  List of file paths containing matches, one per line
126
126
  """
127
- files = await grep_files(pattern, path, include)
127
+ files = await grep_files(ctx, pattern, path, include)
128
128
  if files and files[0].startswith("Error:"):
129
129
  return files[0]
130
130
  return "\n".join(files) if files else "No files found containing pattern"
@@ -150,7 +150,7 @@ async def ls(
150
150
  Returns:
151
151
  List of entries in the directory, one per line
152
152
  """
153
- entries = await list_directory(path, ignore)
153
+ entries = await list_directory(ctx, path, ignore)
154
154
  if entries and entries[0].startswith("Error:"):
155
155
  return entries[0]
156
156
  return "\n".join(entries) if entries else "Empty directory"
@@ -1,10 +1,11 @@
1
1
  from enum import StrEnum
2
2
 
3
3
  from textual.app import ComposeResult
4
+ from textual.containers import Horizontal
4
5
  from textual.content import Content
5
6
  from textual.reactive import reactive
6
7
  from textual.widget import Widget
7
- from textual.widgets import Markdown, Static
8
+ from textual.widgets import Button, Markdown, Static
8
9
 
9
10
 
10
11
  class MessageStatus(StrEnum):
@@ -167,6 +168,19 @@ class AgentMessage(BaseMessage):
167
168
  """Get parameters for MessageHeader."""
168
169
  return ("⏺", self.text, True)
169
170
 
171
+ def compose(self) -> ComposeResult:
172
+ """Create child widgets for the agent message."""
173
+ prefix, text, use_markdown = self.get_header_params()
174
+ with Horizontal(classes="agent-message-header"):
175
+ yield MessageHeader(prefix, text, status=self.status, use_markdown=use_markdown)
176
+ yield Button("Copy", classes="copy-button", variant="primary")
177
+
178
+ def on_button_pressed(self, event: Button.Pressed) -> None:
179
+ """Handle button press events."""
180
+ if event.button.has_class("copy-button"):
181
+ # Copy the markdown text to clipboard
182
+ self.app.copy_to_clipboard(self.text)
183
+
170
184
  def update(self, text: str, status: MessageStatus | None = None) -> None:
171
185
  """Update the text of the agent message."""
172
186
  self.text = text
@@ -62,6 +62,34 @@ UserMessage {
62
62
 
63
63
  AgentMessage {
64
64
  color: $text;
65
+
66
+ Horizontal.agent-message-header {
67
+ height: auto;
68
+ width: 1fr;
69
+ layers: main button;
70
+
71
+ .copy-button {
72
+ layer: button;
73
+ dock: right;
74
+ height: 1;
75
+ width: 8;
76
+ min-width: 8;
77
+ margin: 0;
78
+ padding: 0;
79
+ background: $secondary;
80
+ color: $text;
81
+ border: none;
82
+
83
+ &:hover {
84
+ background: $secondary-lighten-1;
85
+ }
86
+
87
+ &:focus {
88
+ background: $secondary-lighten-1;
89
+ text-style: bold;
90
+ }
91
+ }
92
+ }
65
93
  }
66
94
 
67
95
  SystemMessage {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vibecore
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Build your own AI-powered automation tools in the terminal with this extensible agent framework
5
5
  Project-URL: Homepage, https://github.com/serialx/vibecore
6
6
  Project-URL: Repository, https://github.com/serialx/vibecore
@@ -414,6 +414,38 @@ uv run ruff check . && uv run ruff format --check . && uv run pyright . && uv ru
414
414
 
415
415
  ## Configuration
416
416
 
417
+ ### Path Confinement (Security)
418
+
419
+ vibecore includes a path confinement system that restricts file and shell operations to specified directories for enhanced security. This prevents agents from accessing sensitive system files or directories outside your project.
420
+
421
+ #### Configuration Options
422
+
423
+ ```yaml
424
+ # config.yaml
425
+ path_confinement:
426
+ enabled: true # Enable/disable path confinement (default: true)
427
+ allowed_directories: # List of allowed directories (default: [current working directory])
428
+ - /home/user/projects
429
+ - /tmp
430
+ allow_home: false # Allow access to user's home directory (default: false)
431
+ allow_temp: true # Allow access to system temp directory (default: true)
432
+ strict_mode: false # Strict validation mode (default: false)
433
+ ```
434
+
435
+ Or via environment variables:
436
+ ```bash
437
+ export VIBECORE_PATH_CONFINEMENT__ENABLED=true
438
+ export VIBECORE_PATH_CONFINEMENT__ALLOWED_DIRECTORIES='["/home/user/projects", "/tmp"]'
439
+ export VIBECORE_PATH_CONFINEMENT__ALLOW_HOME=false
440
+ export VIBECORE_PATH_CONFINEMENT__ALLOW_TEMP=true
441
+ ```
442
+
443
+ When enabled, the path confinement system:
444
+ - Validates all file read/write/edit operations
445
+ - Checks paths in shell commands before execution
446
+ - Resolves symlinks to prevent escapes
447
+ - Blocks access to files outside allowed directories
448
+
417
449
  ### Reasoning Effort
418
450
 
419
451
  - Set default via env var: `VIBECORE_REASONING_EFFORT` (minimal | low | medium | high)
@@ -475,6 +507,7 @@ vibecore is built with a modular, extensible architecture:
475
507
 
476
508
  ## Recent Updates
477
509
 
510
+ - **Path Confinement**: New security feature to restrict file and shell operations to specified directories
478
511
  - **Reasoning View**: New ReasoningMessage widget with live reasoning summaries during streaming
479
512
  - **Context Usage Bar & CWD**: Footer shows token usage progress and current working directory
480
513
  - **Keyboard & Commands**: Ctrl+Shift+D toggles theme, Esc cancels, Ctrl+D double-press to exit, `/help` and `/clear` commands
@@ -487,7 +520,7 @@ vibecore is built with a modular, extensible architecture:
487
520
  - [x] More custom tool views (Python, Read, Todo widgets)
488
521
  - [x] Automation (vibecore -p "prompt")
489
522
  - [x] MCP (Model Context Protocol) support
490
- - [ ] Permission model
523
+ - [x] Path confinement for security
491
524
  - [ ] Multi-agent system (agent-as-tools)
492
525
  - [ ] Plugin system for custom tools
493
526
  - [ ] Automated workflow
@@ -1,11 +1,11 @@
1
1
  vibecore/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  vibecore/cli.py,sha256=HU2cjvEDzMWlauoArUItBlTpsVAdgVuKFMLshtaqvUE,7119
3
- vibecore/context.py,sha256=JUVkZpmKGUSlcchrHpxu-oSO8D21GDgHT1BXDzZDTeQ,844
3
+ vibecore/context.py,sha256=SZdWOOBKOPBRVC66Y3NohkWdxR5dbAycCHynuqhmEFo,2577
4
4
  vibecore/flow.py,sha256=ZaKzMsz4YBvgelVzJOIHnTJzMWTmvkfvudwW_hllq6U,3384
5
5
  vibecore/main.py,sha256=MIn7Mpg_xO_20c6Mju8PgY-MmVCTER9cepHd5YFbtYs,20175
6
6
  vibecore/main.tcss,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  vibecore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- vibecore/settings.py,sha256=PwivzoJ-xyJu-Mmnv4E6G_E8up4GNvvz9_j4ttj-GUw,7063
8
+ vibecore/settings.py,sha256=0hugs44VOKB47ysmkJ1BqZnfR8-Lvm_8OmEt3VTUjak,8471
9
9
  vibecore/agents/default.py,sha256=wxeP3Hsq9MVBChyMF_sNkVHCtFFXgy5ghDIxy9eH_fQ,2287
10
10
  vibecore/agents/prompts.py,sha256=0oO9QzcytIbzgZcKJQjWD9fSRNQqBqqK5ku0fcYZZrA,324
11
11
  vibecore/agents/task.py,sha256=cdhZDzKDA8eHHNYPhogFIKBms3oVAMvYeiBB2GqYNuE,1898
@@ -34,9 +34,10 @@ vibecore/session/loader.py,sha256=vmDwzjtedFEeWhaFa6HbygjL32-bSNXM6KccQC9hyJs,68
34
34
  vibecore/session/path_utils.py,sha256=_meng4PnOR59ekPWp_WICkt8yVkokt8c6oePZvk3m-4,2544
35
35
  vibecore/tools/__init__.py,sha256=nppfKiflvkQRUotBrj9nFU0veWex1DE_YX1fg67SRlw,37
36
36
  vibecore/tools/base.py,sha256=POI1bM89qDWlQ5VfcdUFoIg_Tv5Rlrd6sQTRLj-4YmQ,658
37
+ vibecore/tools/path_validator.py,sha256=3Dob33-A6aNy92UeFAEEQME10V_P4N6ZODf7o5H6fYU,8566
37
38
  vibecore/tools/file/__init__.py,sha256=EhdebEC6JTaiROkpItoJWK2lGEq2B5ReNL_IRqxZX5w,147
38
- vibecore/tools/file/executor.py,sha256=xCHaER8_WVRGSLtfSiYF8M5CNkgB26VaeUbesyD2nuU,10236
39
- vibecore/tools/file/tools.py,sha256=CUl9c78WioC3hkw9GD7vSR-YGJBjVnWpHaPTMdKoo5k,8325
39
+ vibecore/tools/file/executor.py,sha256=KRwXZD_n2ypQ8m7kAz06WFgboIW8lJonOBZ_z-fZFo4,12079
40
+ vibecore/tools/file/tools.py,sha256=qkR_IxYHaNfjWkLjiA3Y5Uvq6jXycZWvlMAYWE_cCkM,8345
40
41
  vibecore/tools/file/utils.py,sha256=0Gef8HZgq520pqYgsF8n4cL9FNtzA7nYEr8bBCZVnro,2356
41
42
  vibecore/tools/python/__init__.py,sha256=bqSKgP2pY3bArCmQxOsWFflfmASq3SybOlrmZiz9y4s,35
42
43
  vibecore/tools/python/helpers.py,sha256=y-qwCQ4aRMVUZU6F9OpjgrbzsIGn8i16idXEWR5MBNU,2918
@@ -45,8 +46,8 @@ vibecore/tools/python/tools.py,sha256=OBz9csUUk90Pq3QwHEkz49NcJhgK-3MhaCeCd-mGPO
45
46
  vibecore/tools/python/backends/__init__.py,sha256=RTfU7AzlVyDSaldfVNdKAgv4759RQAl07-UFGqc70Oo,50
46
47
  vibecore/tools/python/backends/terminal_backend.py,sha256=3PA4haJN-dyIvnudx6qfx58ThjaeT7DULnCvhacADbw,1908
47
48
  vibecore/tools/shell/__init__.py,sha256=Ias6qmBMDK29q528VtUGtCQeYD4RU_Yx73SIAJrB8No,133
48
- vibecore/tools/shell/executor.py,sha256=yXUkbPqLc3anlsLUB_g4yEu1A_QpzfzwsoMAqx-gplA,6933
49
- vibecore/tools/shell/tools.py,sha256=hpftFrv4JWn7mbYLJwpCPLROTFyj-RiAOg1hyecV0bE,6829
49
+ vibecore/tools/shell/executor.py,sha256=9bEXHU83fXo5zCIS0ZcOeF6ZabCcE4zaNqVGLHJnPKk,9333
50
+ vibecore/tools/shell/tools.py,sha256=EgbNP5d-MaGfy_UM451GHDLsYUJsSvspQUCvpKzbu9s,6849
50
51
  vibecore/tools/task/__init__.py,sha256=Fyw33zGiBArMnPuRMm7qwSYE6ZRPCZVbHK6eIUJDiJY,112
51
52
  vibecore/tools/task/executor.py,sha256=gRIdq0f2gjDKxnWH-b5Rbmk1H2garIs56EDYFVKfUiw,1606
52
53
  vibecore/tools/task/tools.py,sha256=m6MBOQC3Pz07TZgd3lVAHPGQu9M-Ted-YOxQvIPrGvo,2257
@@ -73,13 +74,13 @@ vibecore/widgets/expandable.py,sha256=GIcXXzVGr4BdyATphUrHZqB30DF_WgeyrccjEIf7FW
73
74
  vibecore/widgets/expandable.tcss,sha256=zmm5zDDabvXiePwwsuSGLPkxHUYunEjmwkp5XrTjxSw,1343
74
75
  vibecore/widgets/info.py,sha256=hXtsRUOE13oHbIm9FNe1GCUX_FCht28pgT9SQWeJ69I,1567
75
76
  vibecore/widgets/info.tcss,sha256=v30IqNt1two-ezIcm18ZEInKRKcRkAW-h-UH2r8QzSo,201
76
- vibecore/widgets/messages.py,sha256=az4fJtdk3ItSoFZBG_adRDUHdTLttIV8F23E8LOb-mg,8156
77
- vibecore/widgets/messages.tcss,sha256=WtBbjf5LgFkUhzhVlxJB7NMbagWladJawDizvDm7hBE,1271
77
+ vibecore/widgets/messages.py,sha256=2m821HLKH2K0Tigt0rN5hMH3uoIEJdoVguLvPJvD16o,8849
78
+ vibecore/widgets/messages.tcss,sha256=Dhz6X1Fkj2XN9bVGVH_hBelDF7WXNE6hHMkGQRQy1QA,1911
78
79
  vibecore/widgets/tool_message_factory.py,sha256=yrZorT4HKo5b6rWUc0dgQle7q7cvLyq8JllE772RZS0,5730
79
80
  vibecore/widgets/tool_messages.py,sha256=hJOolN3iLTAjqfotfH1elXqsdDo1r_UHjsyRVH0GAeo,29415
80
81
  vibecore/widgets/tool_messages.tcss,sha256=gdChmHClURqn_sD9GkcOGQcQVYvUUl75mLUYp85sKz8,8442
81
- vibecore-0.3.1.dist-info/METADATA,sha256=wCQB7NKn35T0W3onSrpV1FNX13zHzO-VEn0WqU6ZIlw,18093
82
- vibecore-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
83
- vibecore-0.3.1.dist-info/entry_points.txt,sha256=i9mOKvpz07ciV_YYisxNCYZ53_Crjkn9mciiQ3aA6QM,51
84
- vibecore-0.3.1.dist-info/licenses/LICENSE,sha256=KXxxifvrcreHrZ4aOYgP-vA8DRHHueW389KKOeEbtjc,1069
85
- vibecore-0.3.1.dist-info/RECORD,,
82
+ vibecore-0.4.0.dist-info/METADATA,sha256=4MbnR28_1JwTXNKo5ldLZiBCIhRr8-2aCNPCZVbAfwo,19550
83
+ vibecore-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
84
+ vibecore-0.4.0.dist-info/entry_points.txt,sha256=i9mOKvpz07ciV_YYisxNCYZ53_Crjkn9mciiQ3aA6QM,51
85
+ vibecore-0.4.0.dist-info/licenses/LICENSE,sha256=KXxxifvrcreHrZ4aOYgP-vA8DRHHueW389KKOeEbtjc,1069
86
+ vibecore-0.4.0.dist-info/RECORD,,