janito 2.5.1__py3-none-any.whl → 2.6.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.
Files changed (61) hide show
  1. janito/agent/setup_agent.py +231 -223
  2. janito/agent/templates/profiles/system_prompt_template_software_developer.txt.j2 +39 -0
  3. janito/cli/chat_mode/bindings.py +1 -26
  4. janito/cli/chat_mode/session.py +282 -294
  5. janito/cli/chat_mode/session_profile_select.py +125 -55
  6. janito/cli/chat_mode/shell/commands/tools.py +51 -48
  7. janito/cli/chat_mode/toolbar.py +42 -68
  8. janito/cli/cli_commands/list_tools.py +41 -56
  9. janito/cli/cli_commands/show_system_prompt.py +70 -49
  10. janito/cli/core/runner.py +6 -1
  11. janito/cli/core/setters.py +43 -34
  12. janito/cli/main_cli.py +25 -1
  13. janito/cli/prompt_core.py +76 -69
  14. janito/cli/rich_terminal_reporter.py +22 -1
  15. janito/cli/single_shot_mode/handler.py +95 -94
  16. janito/drivers/driver_registry.py +27 -29
  17. janito/drivers/openai/driver.py +436 -494
  18. janito/llm/agent.py +54 -68
  19. janito/provider_registry.py +178 -178
  20. janito/providers/anthropic/model_info.py +41 -22
  21. janito/providers/anthropic/provider.py +80 -67
  22. janito/providers/provider_static_info.py +18 -17
  23. janito/tools/adapters/local/__init__.py +66 -65
  24. janito/tools/adapters/local/adapter.py +79 -18
  25. janito/tools/adapters/local/create_directory.py +9 -9
  26. janito/tools/adapters/local/create_file.py +12 -12
  27. janito/tools/adapters/local/delete_text_in_file.py +16 -16
  28. janito/tools/adapters/local/find_files.py +2 -2
  29. janito/tools/adapters/local/get_file_outline/core.py +5 -5
  30. janito/tools/adapters/local/get_file_outline/search_outline.py +4 -4
  31. janito/tools/adapters/local/open_html_in_browser.py +15 -15
  32. janito/tools/adapters/local/python_file_run.py +4 -4
  33. janito/tools/adapters/local/read_files.py +40 -0
  34. janito/tools/adapters/local/remove_directory.py +5 -5
  35. janito/tools/adapters/local/remove_file.py +4 -4
  36. janito/tools/adapters/local/replace_text_in_file.py +21 -21
  37. janito/tools/adapters/local/run_bash_command.py +1 -1
  38. janito/tools/adapters/local/search_text/pattern_utils.py +2 -2
  39. janito/tools/adapters/local/search_text/traverse_directory.py +10 -10
  40. janito/tools/adapters/local/validate_file_syntax/core.py +7 -7
  41. janito/tools/adapters/local/validate_file_syntax/css_validator.py +2 -2
  42. janito/tools/adapters/local/validate_file_syntax/html_validator.py +7 -7
  43. janito/tools/adapters/local/validate_file_syntax/js_validator.py +2 -2
  44. janito/tools/adapters/local/validate_file_syntax/json_validator.py +2 -2
  45. janito/tools/adapters/local/validate_file_syntax/markdown_validator.py +2 -2
  46. janito/tools/adapters/local/validate_file_syntax/ps1_validator.py +2 -2
  47. janito/tools/adapters/local/validate_file_syntax/python_validator.py +2 -2
  48. janito/tools/adapters/local/validate_file_syntax/xml_validator.py +2 -2
  49. janito/tools/adapters/local/validate_file_syntax/yaml_validator.py +2 -2
  50. janito/tools/adapters/local/view_file.py +12 -12
  51. janito/tools/path_security.py +204 -0
  52. janito/tools/tool_use_tracker.py +12 -12
  53. janito/tools/tools_adapter.py +66 -34
  54. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/METADATA +412 -412
  55. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/RECORD +59 -58
  56. janito/drivers/anthropic/driver.py +0 -113
  57. janito/tools/adapters/local/get_file_outline/python_outline_v2.py +0 -156
  58. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/WHEEL +0 -0
  59. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/entry_points.txt +0 -0
  60. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/licenses/LICENSE +0 -0
  61. {janito-2.5.1.dist-info → janito-2.6.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- def validate_yaml(file_path: str) -> str:
1
+ def validate_yaml(path: str) -> str:
2
2
  import yaml
3
3
 
4
- with open(file_path, "r", encoding="utf-8") as f:
4
+ with open(path, "r", encoding="utf-8") as f:
5
5
  yaml.safe_load(f)
6
6
  return "✅ OK"
@@ -11,11 +11,11 @@ class ViewFileTool(ToolBase):
11
11
  Read lines from a file. You can specify a line range, or read the entire file by simply omitting the from_line and to_line parameters.
12
12
 
13
13
  Args:
14
- file_path (str): Path to the file to read lines from.
14
+ path (str): Path to the file to read lines from.
15
15
  from_line (int, optional): Starting line number (1-based). Omit to start from the first line.
16
16
  to_line (int, optional): Ending line number (1-based). Omit to read to the end of the file.
17
17
 
18
- To read the full file, just provide file_path and leave from_line and to_line unset.
18
+ To read the full file, just provide path and leave from_line and to_line unset.
19
19
 
20
20
  Returns:
21
21
  str: File content with a header indicating the file name and line range. Example:
@@ -28,19 +28,19 @@ class ViewFileTool(ToolBase):
28
28
  permissions = ToolPermissions(read=True)
29
29
  tool_name = "view_file"
30
30
 
31
- def run(self, file_path: str, from_line: int = None, to_line: int = None) -> str:
31
+ def run(self, path: str, from_line: int = None, to_line: int = None) -> str:
32
32
  import os
33
33
  from janito.tools.tool_utils import display_path
34
34
 
35
- disp_path = display_path(file_path)
35
+ disp_path = display_path(path)
36
36
  self.report_action(
37
37
  tr("📖 View '{disp_path}'", disp_path=disp_path),
38
38
  ReportAction.READ,
39
39
  )
40
40
  try:
41
- if os.path.isdir(file_path):
42
- return self._list_directory(file_path, disp_path)
43
- lines = self._read_file_lines(file_path)
41
+ if os.path.isdir(path):
42
+ return self._list_directory(path, disp_path)
43
+ lines = self._read_file_lines(path)
44
44
  selected, selected_len, total_lines = self._select_lines(
45
45
  lines, from_line, to_line
46
46
  )
@@ -56,16 +56,16 @@ class ViewFileTool(ToolBase):
56
56
  self.report_error(tr(" ❌ Error: {error}", error=e))
57
57
  return tr("Error reading file: {error}", error=e)
58
58
 
59
- def _list_directory(self, file_path, disp_path):
59
+ def _list_directory(self, path, disp_path):
60
60
  import os
61
61
 
62
62
  try:
63
- entries = os.listdir(file_path)
63
+ entries = os.listdir(path)
64
64
  entries.sort()
65
65
  # Suffix subdirectories with '/'
66
66
  formatted_entries = []
67
67
  for entry in entries:
68
- full_path = os.path.join(file_path, entry)
68
+ full_path = os.path.join(path, entry)
69
69
  if os.path.isdir(full_path):
70
70
  formatted_entries.append(entry + "/")
71
71
  else:
@@ -80,9 +80,9 @@ class ViewFileTool(ToolBase):
80
80
  self.report_error(tr(" ❌ Error listing directory: {error}", error=e))
81
81
  return tr("Error listing directory: {error}", error=e)
82
82
 
83
- def _read_file_lines(self, file_path):
83
+ def _read_file_lines(self, path):
84
84
  """Read all lines from the file."""
85
- with open(file_path, "r", encoding="utf-8", errors="replace") as f:
85
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
86
86
  return f.readlines()
87
87
 
88
88
  def _select_lines(self, lines, from_line, to_line):
@@ -0,0 +1,204 @@
1
+ """janito.tools.path_security
2
+ ================================
3
+ Utilities that ensure user-supplied file-system paths never escape the allowed
4
+ workspace.
5
+
6
+ Public interface
7
+ ----------------
8
+
9
+ ``is_path_within_workdir(path, workdir)``
10
+ Verify that *path* is located **inside** *workdir* (or equals it). If
11
+ *workdir* is *None* every path is accepted.
12
+
13
+ ``validate_paths_in_arguments(arguments, workdir, *, schema=None)``
14
+ Inspect a mapping of arguments (typically the kwargs that will later be
15
+ passed to a tool adapter). Any item whose key *looks* like it refers to a
16
+ path is validated with :func:`is_path_within_workdir`. If a JSON Schema for
17
+ the tool is provided, the keys that explicitly represent paths are derived
18
+ from it. Otherwise a simple heuristic based on the key name is used.
19
+
20
+ Both helpers raise :class:`PathSecurityError` if a path tries to escape the
21
+ workspace.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from typing import Any, Mapping
27
+
28
+ __all__ = [
29
+ "PathSecurityError",
30
+ "is_path_within_workdir",
31
+ "validate_paths_in_arguments",
32
+ ]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Exceptions
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class PathSecurityError(Exception):
41
+ """Raised when an argument references a location outside the workspace."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Public helpers
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def is_path_within_workdir(path: str, workdir: str | None) -> bool: # noqa: D401 – we start with an imperative verb # noqa: D401 – we start with an imperative verb
50
+ """Return *True* if *path* is located inside *workdir* (or equals it).
51
+
52
+ Relative *path*s are **resolved relative to the *workdir***, *not* to the
53
+ current working directory. This behaviour makes the security checks
54
+ deterministic regardless of the directory from which the Python process was
55
+ started.
56
+
57
+ Implementation details
58
+ ----------------------
59
+ The function converts both *workdir* and *path* to absolute paths and then
60
+ uses :func:`os.path.commonpath` to determine the longest common sub-path. A
61
+ path is considered *inside* the workspace when that common part equals the
62
+ workspace directory itself.
63
+ """
64
+ if not workdir:
65
+ # No workdir configured – everything is implicitly allowed.
66
+ return True
67
+
68
+ abs_workdir = os.path.abspath(workdir)
69
+
70
+ # Resolve *path* – if it is *relative* we interpret it **relative to the
71
+ # workspace** (and *not* to the current working directory!) so that a value
72
+ # like '.' always points inside the workspace.
73
+ if os.path.isabs(path):
74
+ abs_path = os.path.abspath(path)
75
+ else:
76
+ abs_path = os.path.abspath(os.path.join(abs_workdir, path))
77
+
78
+ try:
79
+ common_part = os.path.commonpath([abs_workdir, abs_path])
80
+ except ValueError:
81
+ # On Windows different drive letters cause ValueError → definitely
82
+ # outside the workspace.
83
+ return False
84
+
85
+ # Additionally allow files located inside the system temporary directory.
86
+ import tempfile
87
+ abs_tempdir = os.path.abspath(tempfile.gettempdir())
88
+ try:
89
+ common_temp = os.path.commonpath([abs_tempdir, abs_path])
90
+ except ValueError:
91
+ common_temp = None
92
+ if common_temp == abs_tempdir:
93
+ return True
94
+
95
+ return common_part == abs_workdir
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Helper for tool adapters
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def _looks_like_path_key(key: str) -> bool:
104
+ """Return *True* when *key* likely refers to a file-system path."""
105
+ key_lower = key.lower()
106
+ if key_lower in {
107
+ "path",
108
+ "paths",
109
+ "filepath",
110
+ "file",
111
+ "filename",
112
+ "directory",
113
+ "directories",
114
+ "dir",
115
+ "dirs",
116
+ "target",
117
+ "targets",
118
+ "source",
119
+ "sources",
120
+ }:
121
+ return True
122
+
123
+ common_suffixes = ("path", "paths", "file", "dir", "dirs")
124
+ return key_lower.endswith(common_suffixes)
125
+
126
+
127
+ def _extract_path_keys_from_schema(schema: Mapping[str, Any]) -> set[str]:
128
+ """Extract keys that represent paths from the provided JSON schema."""
129
+ path_keys: set[str] = set()
130
+ if schema is not None:
131
+ for k, v in schema.get("properties", {}).items():
132
+ if (
133
+ v.get("format") == "path"
134
+ or (
135
+ v.get("type") == "string"
136
+ and (
137
+ "path" in v.get("description", "").lower()
138
+ or k.endswith("path")
139
+ or k == "path"
140
+ )
141
+ )
142
+ ):
143
+ path_keys.add(k)
144
+ return path_keys
145
+
146
+ def _validate_argument_value(key: str, value: Any, workdir: str) -> None:
147
+ """Validate a single argument value (string or list of strings) for path security."""
148
+ # Single string argument → validate directly.
149
+ if isinstance(value, str) and value.strip():
150
+ if not is_path_within_workdir(value, workdir):
151
+ _raise_outside_workspace_error(key, value, workdir)
152
+ # Sequence of potential paths → validate every item.
153
+ elif isinstance(value, list):
154
+ for item in value:
155
+ if isinstance(item, str) and item.strip():
156
+ if not is_path_within_workdir(item, workdir):
157
+ _raise_outside_workspace_error(key, item, workdir)
158
+
159
+ def validate_paths_in_arguments(
160
+ arguments: Mapping[str, Any] | None,
161
+ workdir: str | None,
162
+ *,
163
+ schema: Mapping[str, Any] | None = None,
164
+ ) -> None:
165
+ """Ensure every *path-looking* value in *arguments* is inside *workdir*.
166
+
167
+ The function walks through *arguments* and raises :class:`PathSecurityError`
168
+ if it finds a suspicious path that points outside the allowed workspace.
169
+
170
+ If *schema* is given it is expected to be the JSON Schema describing the
171
+ tool's arguments (as produced by ``janito.tools.inspect_registry``). Keys
172
+ whose schema declares a ``"format": "path"`` or mentions "path" in the
173
+ description are treated as path parameters. Without a schema the function
174
+ falls back to a simple heuristic based on the argument name.
175
+ """
176
+ if not workdir or not arguments:
177
+ return
178
+
179
+ path_keys = _extract_path_keys_from_schema(schema) if schema is not None else set()
180
+
181
+ for key, value in arguments.items():
182
+ key_is_path = key in path_keys or _looks_like_path_key(key)
183
+ if not key_is_path:
184
+ continue
185
+ _validate_argument_value(key, value, workdir)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Internal helpers
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def _raise_outside_workspace_error(key: str, path: str, workdir: str) -> None: # noqa: D401
194
+ """Raise a consistent :class:`PathSecurityError` for *path*."""
195
+ abs_workdir = os.path.abspath(workdir)
196
+ attempted = (
197
+ os.path.abspath(path)
198
+ if os.path.isabs(path)
199
+ else os.path.abspath(os.path.join(abs_workdir, path))
200
+ )
201
+ raise PathSecurityError(
202
+ f"Argument '{key}' path '{path}' is not within allowed workdir '{workdir}' "
203
+ f"[attempted path: {attempted}]"
204
+ )
@@ -22,10 +22,10 @@ class ToolUseTracker:
22
22
  return cls._instance
23
23
 
24
24
  def record(self, tool_name: str, params: Dict[str, Any], result: Any = None):
25
- # Normalize file_path in params if present
25
+ # Normalize path in params if present
26
26
  norm_params = params.copy()
27
- if "file_path" in norm_params:
28
- norm_params["file_path"] = normalize_path(norm_params["file_path"])
27
+ if "path" in norm_params:
28
+ norm_params["path"] = normalize_path(norm_params["path"])
29
29
  self._history.append(
30
30
  {"tool": tool_name, "params": norm_params, "result": result}
31
31
  )
@@ -33,26 +33,26 @@ class ToolUseTracker:
33
33
  def get_history(self) -> List[Dict[str, Any]]:
34
34
  return list(self._history)
35
35
 
36
- def get_operations_on_file(self, file_path: str) -> List[Dict[str, Any]]:
37
- norm_file_path = normalize_path(file_path)
36
+ def get_operations_on_file(self, path: str) -> List[Dict[str, Any]]:
37
+ norm_path = normalize_path(path)
38
38
  ops = []
39
39
  for entry in self._history:
40
40
  params = entry["params"]
41
41
  # Normalize any string param values for comparison
42
42
  for v in params.values():
43
- if isinstance(v, str) and normalize_path(v) == norm_file_path:
43
+ if isinstance(v, str) and normalize_path(v) == norm_path:
44
44
  ops.append(entry)
45
45
  break
46
46
  return ops
47
47
 
48
- def file_fully_read(self, file_path: str) -> bool:
49
- norm_file_path = normalize_path(file_path)
48
+ def file_fully_read(self, path: str) -> bool:
49
+ norm_path = normalize_path(path)
50
50
  for entry in self._history:
51
51
  if entry["tool"] == "view_file":
52
52
  params = entry["params"]
53
53
  if (
54
- "file_path" in params
55
- and normalize_path(params["file_path"]) == norm_file_path
54
+ "path" in params
55
+ and normalize_path(params["path"]) == norm_path
56
56
  ):
57
57
  # If both from_line and to_line are None, full file was read
58
58
  if (
@@ -62,8 +62,8 @@ class ToolUseTracker:
62
62
  return True
63
63
  return False
64
64
 
65
- def last_operation_is_full_read_or_replace(self, file_path: str) -> bool:
66
- ops = self.get_operations_on_file(file_path)
65
+ def last_operation_is_full_read_or_replace(self, path: str) -> bool:
66
+ ops = self.get_operations_on_file(path)
67
67
  if not ops:
68
68
  return False
69
69
  last = ops[-1]
@@ -162,60 +162,92 @@ class ToolsAdapterBase:
162
162
  tool = self.get_tool(tool_name)
163
163
  self._ensure_tool_exists(tool, tool_name, request_id, arguments)
164
164
  func = self._get_tool_callable(tool)
165
- # First, validate arguments against the callable signature to catch unexpected / missing params
165
+
166
+ validation_error = self._validate_tool_arguments(tool, func, arguments, tool_name, request_id)
167
+ if validation_error:
168
+ return validation_error
169
+
170
+ # --- SECURITY: Path restriction enforcement ---
171
+ if not getattr(self, 'unrestricted_paths', False):
172
+ workdir = getattr(self, 'workdir', None)
173
+ # Ensure workdir is always set; default to current working directory.
174
+ if not workdir:
175
+ import os
176
+ workdir = os.getcwd()
177
+ from janito.tools.path_security import validate_paths_in_arguments, PathSecurityError
178
+ schema = getattr(tool, 'schema', None)
179
+ try:
180
+ validate_paths_in_arguments(arguments, workdir, schema=schema)
181
+ except PathSecurityError as sec_err:
182
+ # Publish both a ToolCallError and a user-facing ReportEvent for path security errors
183
+ self._publish_tool_call_error(tool_name, request_id, str(sec_err), arguments)
184
+ if self._event_bus:
185
+ from janito.report_events import ReportEvent, ReportSubtype, ReportAction
186
+ self._event_bus.publish(
187
+ ReportEvent(
188
+ subtype=ReportSubtype.ERROR,
189
+ message=f"[SECURITY] Path access denied: {sec_err}",
190
+ action=ReportAction.EXECUTE,
191
+ tool=tool_name,
192
+ context={"arguments": arguments, "request_id": request_id}
193
+ )
194
+ )
195
+ return f"Security error: {sec_err}"
196
+ # --- END SECURITY ---
197
+
198
+ self._publish_tool_call_started(tool_name, request_id, arguments)
199
+ self._print_verbose(f"[tools-adapter] Executing tool: {tool_name} with arguments: {arguments}")
200
+ try:
201
+ result = self.execute(tool, **(arguments or {}), **kwargs)
202
+ except Exception as e:
203
+ self._handle_execution_error(tool_name, request_id, e, arguments)
204
+ self._print_verbose(f"[tools-adapter] Tool execution finished: {tool_name} -> {result}")
205
+ self._publish_tool_call_finished(tool_name, request_id, result)
206
+ return result
207
+
208
+ def _validate_tool_arguments(self, tool, func, arguments, tool_name, request_id):
166
209
  sig_error = self._validate_arguments_against_signature(func, arguments)
167
210
  if sig_error:
168
- if self._event_bus:
169
- self._event_bus.publish(
170
- ToolCallError(
171
- tool_name=tool_name,
172
- request_id=request_id,
173
- error=sig_error,
174
- arguments=arguments,
175
- )
176
- )
211
+ self._publish_tool_call_error(tool_name, request_id, sig_error, arguments)
177
212
  return sig_error
178
-
179
- # Optionally validate against JSON schema if available
180
213
  schema = getattr(tool, "schema", None)
181
214
  if schema and arguments is not None:
182
- validation_error = self._validate_arguments_against_schema(
183
- arguments, schema
184
- )
215
+ validation_error = self._validate_arguments_against_schema(arguments, schema)
185
216
  if validation_error:
186
- if self._event_bus:
187
- self._event_bus.publish(
188
- ToolCallError(
189
- tool_name=tool_name,
190
- request_id=request_id,
191
- error=validation_error,
192
- arguments=arguments,
193
- )
194
- )
217
+ self._publish_tool_call_error(tool_name, request_id, validation_error, arguments)
195
218
  return validation_error
196
- if self.verbose_tools:
197
- print(
198
- f"[tools-adapter] Executing tool: {tool_name} with arguments: {arguments}"
219
+ return None
220
+
221
+ def _publish_tool_call_error(self, tool_name, request_id, error, arguments):
222
+ if self._event_bus:
223
+ self._event_bus.publish(
224
+ ToolCallError(
225
+ tool_name=tool_name,
226
+ request_id=request_id,
227
+ error=error,
228
+ arguments=arguments,
229
+ )
199
230
  )
231
+
232
+ def _publish_tool_call_started(self, tool_name, request_id, arguments):
200
233
  if self._event_bus:
201
234
  self._event_bus.publish(
202
235
  ToolCallStarted(
203
236
  tool_name=tool_name, request_id=request_id, arguments=arguments
204
237
  )
205
238
  )
206
- try:
207
- result = self.execute(tool, **(arguments or {}), **kwargs)
208
- except Exception as e:
209
- self._handle_execution_error(tool_name, request_id, e, arguments)
210
- if self.verbose_tools:
211
- print(f"[tools-adapter] Tool execution finished: {tool_name} -> {result}")
239
+
240
+ def _publish_tool_call_finished(self, tool_name, request_id, result):
212
241
  if self._event_bus:
213
242
  self._event_bus.publish(
214
243
  ToolCallFinished(
215
244
  tool_name=tool_name, request_id=request_id, result=result
216
245
  )
217
246
  )
218
- return result
247
+
248
+ def _print_verbose(self, message):
249
+ if self.verbose_tools:
250
+ print(message)
219
251
 
220
252
  def execute_function_call_message_part(self, function_call_message_part):
221
253
  """