Skip to content

Commit cbd50e2

Browse files
committed
refactor: centralized tools utilities
1 parent ef325d7 commit cbd50e2

File tree

5 files changed

+133
-68
lines changed

5 files changed

+133
-68
lines changed

nerve/tools/namespaces/filesystem.py

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,23 @@
33
"""
44

55
import os
6-
from pathlib import Path
76
from typing import Annotated
87

8+
from nerve.tools.utils import maybe_text, path_acl
9+
910
# for docs
1011
EMOJI = "📂"
1112

1213
# if set, the agent will only have access to these paths
1314
jail: list[str] = []
1415

1516

16-
def _path_allowed(path_to_check: str) -> bool:
17-
if not jail:
18-
return True
19-
20-
# https://stackoverflow.com/questions/3812849/how-to-check-whether-a-directory-is-a-sub-directory-of-another-directory
21-
path = Path(path_to_check).resolve().absolute()
22-
for allowed_path in jail:
23-
allowed = Path(allowed_path).resolve().absolute()
24-
if path == allowed or allowed in path.parents:
25-
return True
26-
27-
return False
28-
29-
30-
def _path_acl(path_to_check: str) -> None:
31-
if not _path_allowed(path_to_check):
32-
raise ValueError(f"access to path {path_to_check} is not allowed, only allowed paths are: {jail}")
33-
34-
35-
def _maybe_text(output: bytes) -> str | bytes:
36-
try:
37-
return output.decode("utf-8").strip()
38-
except UnicodeDecodeError:
39-
return output
40-
41-
4217
def list_folder_contents(
4318
path: Annotated[str, "The path to the folder to list"],
4419
) -> str:
4520
"""List the contents of a folder on disk."""
4621

47-
_path_acl(path)
22+
path_acl(path, jail)
4823

4924
# The rationale here is that because of training data, models can
5025
# understand an "ls -la" output better than any custom output format
@@ -56,7 +31,7 @@ def list_folder_contents(
5631
def read_file(path: Annotated[str, "The path to the file to read"]) -> str | bytes:
5732
"""Read the contents of a file from disk."""
5833

59-
_path_acl(path)
34+
path_acl(path, jail)
6035

6136
with open(path, "rb") as f:
62-
return _maybe_text(f.read())
37+
return maybe_text(f.read())

nerve/tools/namespaces/filesystem_w.py

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,17 @@
33
"""
44

55
import os
6-
from pathlib import Path
76
from typing import Annotated
87

8+
from nerve.tools.utils import path_acl
9+
910
# for docs
1011
EMOJI = "📂"
1112

1213
# if set, the agent will only have access to these paths
1314
jail: list[str] = []
1415

1516

16-
# TODO: abstract and centralize jail system
17-
def _path_allowed(path_to_check: str) -> bool:
18-
if not jail:
19-
return True
20-
21-
# https://stackoverflow.com/questions/3812849/how-to-check-whether-a-directory-is-a-sub-directory-of-another-directory
22-
path = Path(path_to_check).resolve().absolute()
23-
for allowed_path in jail:
24-
allowed = Path(allowed_path).resolve().absolute()
25-
if path == allowed or allowed in path.parents:
26-
return True
27-
28-
return False
29-
30-
31-
def _path_acl(path_to_check: str) -> None:
32-
if not _path_allowed(path_to_check):
33-
raise ValueError(f"access to path {path_to_check} is not allowed, only allowed paths are: {jail}")
34-
35-
36-
def _maybe_text(output: bytes) -> str | bytes:
37-
try:
38-
return output.decode("utf-8").strip()
39-
except UnicodeDecodeError:
40-
return output
41-
42-
4317
def create_file(
4418
path: Annotated[str, "The path to the file to create"],
4519
content: Annotated[
@@ -48,7 +22,7 @@ def create_file(
4822
) -> str:
4923
"""Create a file on disk, if the file already exists, it will be overwritten."""
5024

51-
_path_acl(path)
25+
path_acl(path, jail)
5226

5327
response = ""
5428

@@ -74,7 +48,7 @@ def create_file(
7448
def delete_file(path: Annotated[str, "The path to the file to delete"]) -> str:
7549
"""Delete a file from disk."""
7650

77-
_path_acl(path)
51+
path_acl(path, jail)
7852

7953
os.remove(path)
8054
return f"File {path} deleted."

nerve/tools/namespaces/shell.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,12 @@
55
import subprocess
66
from typing import Annotated
77

8+
from nerve.tools.utils import maybe_text
9+
810
# for docs
911
EMOJI = "💻"
1012

1113

12-
def _maybe_text(output: bytes) -> str | bytes:
13-
try:
14-
return output.decode("utf-8").strip()
15-
except UnicodeDecodeError:
16-
return output
17-
18-
1914
# TODO: if both filesystem and shell are used, shell can be used to bypass the filesystem jailing system. find a way to either prevent it, or communicate it.
2015
def shell(
2116
command: Annotated[str, "The shell command to execute"],
@@ -35,4 +30,4 @@ def shell(
3530
else:
3631
raw_output += b"\n" + result.stderr
3732

38-
return _maybe_text(raw_output)
33+
return maybe_text(raw_output)

nerve/tools/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from pathlib import Path
2+
3+
4+
def is_path_allowed(path_to_check: str, jail: list[str] | None = None) -> bool:
5+
if not jail:
6+
return True
7+
8+
# https://stackoverflow.com/questions/3812849/how-to-check-whether-a-directory-is-a-sub-directory-of-another-directory
9+
path = Path(path_to_check).resolve().absolute()
10+
for allowed_path in jail:
11+
allowed = Path(allowed_path).resolve().absolute()
12+
if path == allowed or allowed in path.parents:
13+
return True
14+
15+
return False
16+
17+
18+
def path_acl(path_to_check: str, jail: list[str] | None = None) -> None:
19+
if not is_path_allowed(path_to_check, jail):
20+
raise ValueError(f"access to path {path_to_check} is not allowed, only allowed paths are: {jail}")
21+
22+
23+
def maybe_text(output: bytes) -> str | bytes:
24+
try:
25+
return output.decode("utf-8").strip()
26+
except UnicodeDecodeError:
27+
return output

nerve/tools/utils_test.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import os
2+
import tempfile
3+
import unittest
4+
from pathlib import Path
5+
6+
from nerve.tools.utils import is_path_allowed, maybe_text, path_acl
7+
8+
9+
class TestUtils(unittest.TestCase):
10+
def test_maybe_text_with_valid_utf8(self) -> None:
11+
# Test with valid UTF-8 string
12+
input_bytes = b"Hello, world!"
13+
result = maybe_text(input_bytes)
14+
self.assertEqual(result, "Hello, world!")
15+
self.assertIsInstance(result, str)
16+
17+
def test_maybe_text_with_invalid_utf8(self) -> None:
18+
# Test with invalid UTF-8 sequence
19+
input_bytes = bytes([0xFF, 0xFE, 0xFD]) # Invalid UTF-8 sequence
20+
result = maybe_text(input_bytes)
21+
self.assertEqual(result, input_bytes)
22+
self.assertIsInstance(result, bytes)
23+
24+
def test_maybe_text_with_empty_bytes(self) -> None:
25+
# Test with empty bytes
26+
input_bytes = b""
27+
result = maybe_text(input_bytes)
28+
self.assertEqual(result, "")
29+
self.assertIsInstance(result, str)
30+
31+
def test_maybe_text_with_mixed_content(self) -> None:
32+
# Test with mixed content (valid UTF-8 + invalid sequence)
33+
input_bytes = b"Valid text" + bytes([0xFF, 0xFE]) + b"more text"
34+
result = maybe_text(input_bytes)
35+
self.assertEqual(result, input_bytes)
36+
self.assertIsInstance(result, bytes)
37+
38+
def test_maybe_text_with_non_bytes_input(self) -> None:
39+
# Test with non-bytes input (should handle gracefully or raise appropriate error)
40+
with self.assertRaises(AttributeError):
41+
maybe_text("already a string") # type: ignore
42+
43+
def test_path_acl_with_no_jail(self) -> None:
44+
# Test with empty jail list
45+
jail: list[str] = []
46+
path = "/some/random/path"
47+
self.assertTrue(is_path_allowed(path, jail))
48+
49+
# Test path_acl with empty jail
50+
path_acl(path, jail) # Should not raise an exception with empty jail
51+
52+
def test_path_acl_with_jail(self) -> None:
53+
# Test with jail containing paths
54+
jail = ["/allowed/path", "/another/allowed"]
55+
56+
# Test allowed path
57+
allowed_path = "/allowed/path/file.txt"
58+
self.assertTrue(is_path_allowed(allowed_path, jail))
59+
path_acl(allowed_path, jail) # Should not raise an exception
60+
61+
# Test another allowed path
62+
another_allowed = "/another/allowed/subdir/file.txt"
63+
self.assertTrue(is_path_allowed(another_allowed, jail))
64+
path_acl(another_allowed, jail) # Should not raise an exception
65+
66+
# Test disallowed path
67+
disallowed_path = "/disallowed/path/file.txt"
68+
self.assertFalse(is_path_allowed(disallowed_path, jail))
69+
70+
# Test path_acl with disallowed path (should raise ValueError)
71+
with self.assertRaises(ValueError):
72+
path_acl(disallowed_path, jail)
73+
74+
def test_path_acl_with_symlinks(self) -> None:
75+
# Create temporary directories for testing
76+
with tempfile.TemporaryDirectory() as allowed_dir, tempfile.TemporaryDirectory() as disallowed_dir:
77+
allowed_path = Path(allowed_dir)
78+
disallowed_path = Path(disallowed_dir)
79+
80+
# Create a symlink inside allowed directory pointing to disallowed directory
81+
symlink_path = allowed_path / "symlink_to_outside"
82+
os.symlink(str(disallowed_path), str(symlink_path))
83+
84+
jail = [str(allowed_path)]
85+
86+
# Test direct access to allowed path
87+
self.assertTrue(is_path_allowed(str(allowed_path), jail))
88+
89+
# Test symlink that resolves outside jail
90+
symlink_file_path = str(symlink_path / "some_file.txt")
91+
self.assertFalse(is_path_allowed(symlink_file_path, jail))
92+
93+
with self.assertRaises(ValueError):
94+
path_acl(symlink_file_path, jail)

0 commit comments

Comments
 (0)