Skip to content

Commit 0eb6af6

Browse files
committed
Validate positive/non-negative integer args in workspace tools
- Add positive_int_arg and non_negative_int_arg helpers that raise ValueError on invalid input - Apply validation to limit, timeout_seconds, and max_bytes across read_file, list_files, search_text, and shell tools
1 parent 05bd963 commit 0eb6af6

2 files changed

Lines changed: 52 additions & 5 deletions

File tree

teaagent/workspace_tools.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def object_schema(properties: dict[str, Union[str, dict[str, Any]]], *, required
182182

183183
def read_file(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str, Any]:
184184
path = resolve_workspace_path(config, args["path"])
185-
max_bytes = args.get("max_bytes", config.max_read_bytes)
185+
max_bytes = non_negative_int_arg(args, "max_bytes", default=config.max_read_bytes)
186186
data = path.read_bytes()
187187
truncated = len(data) > max_bytes
188188
return {
@@ -241,7 +241,7 @@ def edit_at_hash(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str,
241241

242242
def list_files(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str, Any]:
243243
pattern = args["pattern"]
244-
limit = args.get("limit", 200)
244+
limit = positive_int_arg(args, "limit", default=200)
245245
files = []
246246
for path in sorted(config.root.rglob("*")):
247247
if path.is_file() and ".git" not in path.parts and fnmatch(relative_path(config, path), pattern):
@@ -254,7 +254,7 @@ def list_files(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str, A
254254
def search_text(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str, Any]:
255255
regex = re.compile(args["pattern"])
256256
include = args.get("include", "*")
257-
limit = args.get("limit", 200)
257+
limit = positive_int_arg(args, "limit", default=200)
258258
matches = []
259259
for path in sorted(config.root.rglob("*")):
260260
if not path.is_file() or ".git" in path.parts or not fnmatch(relative_path(config, path), include):
@@ -283,7 +283,7 @@ def git_status(config: WorkspaceToolConfig) -> dict[str, Any]:
283283

284284

285285
def run_shell(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict[str, Any]:
286-
timeout = args.get("timeout_seconds", config.command_timeout_seconds)
286+
timeout = positive_int_arg(args, "timeout_seconds", default=config.command_timeout_seconds)
287287
result = subprocess.run(
288288
args["command"],
289289
cwd=str(config.root),
@@ -319,10 +319,24 @@ def run_shell_inspect(config: WorkspaceToolConfig, args: dict[str, Any]) -> dict
319319
policy = classify_shell_command_policy(args["command"])
320320
if policy != "inspect":
321321
raise ValueError("command is not inspect-safe; retry with workspace_run_shell_mutate")
322-
timeout = args.get("timeout_seconds", config.command_timeout_seconds)
322+
timeout = positive_int_arg(args, "timeout_seconds", default=config.command_timeout_seconds)
323323
return run_shell_argv(config, shlex.split(args["command"]), timeout_seconds=timeout)
324324

325325

326+
def positive_int_arg(args: dict[str, Any], name: str, *, default: int) -> int:
327+
value = args.get(name, default)
328+
if value < 1:
329+
raise ValueError(f"{name} must be >= 1")
330+
return value
331+
332+
333+
def non_negative_int_arg(args: dict[str, Any], name: str, *, default: int) -> int:
334+
value = args.get(name, default)
335+
if value < 0:
336+
raise ValueError(f"{name} must be >= 0")
337+
return value
338+
339+
326340
def classify_shell_command_policy(command: str) -> str:
327341
blocked_tokens = (">", "<", "|", "&&", ";", "`", "$(")
328342
if any(token in command for token in blocked_tokens):

tests/test_workspace_tools.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ def test_path_escape_is_blocked(self) -> None:
8989
with self.assertRaises(ToolExecutionError):
9090
registry.execute("workspace_read_file", {"path": "../outside.txt"})
9191

92+
def test_read_file_rejects_negative_max_bytes(self) -> None:
93+
with tempfile.TemporaryDirectory() as tmp:
94+
(Path(tmp) / "file.txt").write_text("content", encoding="utf-8")
95+
registry = build_workspace_tool_registry(tmp)
96+
97+
with self.assertRaises(ToolExecutionError) as ctx:
98+
registry.execute("workspace_read_file", {"path": "file.txt", "max_bytes": -1})
99+
self.assertIn("max_bytes", str(ctx.exception))
100+
92101
def test_shell_and_git_status_tools(self) -> None:
93102
with tempfile.TemporaryDirectory() as tmp:
94103
registry = build_workspace_tool_registry(tmp)
@@ -138,6 +147,14 @@ def test_shell_inspect_rejects_unbalanced_quotes(self) -> None:
138147
with self.assertRaises(ToolExecutionError):
139148
registry.execute("workspace_run_shell_inspect", {"command": "cat 'unterminated"})
140149

150+
def test_shell_inspect_rejects_non_positive_timeout(self) -> None:
151+
with tempfile.TemporaryDirectory() as tmp:
152+
registry = build_workspace_tool_registry(tmp)
153+
154+
with self.assertRaises(ToolExecutionError) as ctx:
155+
registry.execute("workspace_run_shell_inspect", {"command": "pwd", "timeout_seconds": 0})
156+
self.assertIn("timeout_seconds", str(ctx.exception))
157+
141158
def test_cli_workspace_tools_outputs_metadata(self) -> None:
142159
output = io.StringIO()
143160

@@ -180,6 +197,14 @@ def test_list_files_truncates_when_limit_exceeded(self) -> None:
180197
self.assertEqual(len(result["files"]), 2)
181198
self.assertTrue(result["truncated"])
182199

200+
def test_list_files_rejects_non_positive_limit(self) -> None:
201+
with tempfile.TemporaryDirectory() as tmp:
202+
registry = build_workspace_tool_registry(tmp)
203+
204+
with self.assertRaises(ToolExecutionError) as ctx:
205+
registry.execute("workspace_list_files", {"pattern": "*.txt", "limit": 0})
206+
self.assertIn("limit", str(ctx.exception))
207+
183208
def test_search_text_truncates_when_limit_exceeded(self) -> None:
184209
with tempfile.TemporaryDirectory() as tmp:
185210
for i in range(5):
@@ -190,6 +215,14 @@ def test_search_text_truncates_when_limit_exceeded(self) -> None:
190215
self.assertEqual(len(result["matches"]), 5)
191216
self.assertTrue(result["truncated"])
192217

218+
def test_search_text_rejects_non_positive_limit(self) -> None:
219+
with tempfile.TemporaryDirectory() as tmp:
220+
registry = build_workspace_tool_registry(tmp)
221+
222+
with self.assertRaises(ToolExecutionError) as ctx:
223+
registry.execute("workspace_search_text", {"pattern": "x", "limit": 0})
224+
self.assertIn("limit", str(ctx.exception))
225+
193226

194227
if __name__ == "__main__":
195228
unittest.main()

0 commit comments

Comments
 (0)