agents-function-tools 0.3.0__tar.gz → 0.4.0__tar.gz

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 (30) hide show
  1. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/PKG-INFO +34 -11
  2. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/README.md +33 -10
  3. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/pyproject.toml +1 -1
  4. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/agents_function_tools.egg-info/PKG-INFO +34 -11
  5. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/agents_function_tools.egg-info/SOURCES.txt +3 -0
  6. agents_function_tools-0.4.0/src/function_tools/__init__.py +52 -0
  7. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/http.py +9 -28
  8. agents_function_tools-0.4.0/src/function_tools/network.py +209 -0
  9. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/openai_tools.py +214 -29
  10. agents_function_tools-0.4.0/src/function_tools/process.py +250 -0
  11. agents_function_tools-0.4.0/tests/test_network_and_process.py +134 -0
  12. agents_function_tools-0.4.0/tests/test_openai_tools.py +156 -0
  13. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/tests/test_public_api.py +5 -1
  14. agents_function_tools-0.3.0/src/function_tools/__init__.py +0 -26
  15. agents_function_tools-0.3.0/tests/test_openai_tools.py +0 -70
  16. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/LICENSE +0 -0
  17. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/setup.cfg +0 -0
  18. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/agents_function_tools.egg-info/dependency_links.txt +0 -0
  19. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/agents_function_tools.egg-info/requires.txt +0 -0
  20. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/agents_function_tools.egg-info/top_level.txt +0 -0
  21. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/archive.py +0 -0
  22. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/command.py +0 -0
  23. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/errors.py +0 -0
  24. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/host.py +0 -0
  25. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/responses.py +0 -0
  26. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/src/function_tools/workspace.py +0 -0
  27. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/tests/test_archive.py +0 -0
  28. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/tests/test_command.py +0 -0
  29. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/tests/test_host_and_http.py +0 -0
  30. {agents_function_tools-0.3.0 → agents_function_tools-0.4.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agents-function-tools
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Portable, policy-friendly system function tools for AI applications.
5
5
  License-Expression: Apache-2.0
6
6
  Classifier: Programming Language :: Python :: 3
@@ -26,14 +26,14 @@ python -m pip install agents-function-tools
26
26
 
27
27
  ## Included tools
28
28
 
29
- | Category | Tools | Tool group |
29
+ | Category | Tools | Recommended selector |
30
30
  |---|---|---|
31
- | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `read` |
32
- | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `write` |
33
- | ZIP | List archive entries; create and extract bounded ZIP archives | `read` / `write` |
34
- | Network | Fetch bounded HTTPS text from configured hosts | `read` |
35
- | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `read` |
36
- | Commands | Describe configured aliases; run one allowlisted executable with `shell=False` | `read` / `execute` |
31
+ | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `files.read` |
32
+ | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `files.write` |
33
+ | ZIP | List archive entries; create and extract bounded ZIP archives | `files.read` / `files.write` |
34
+ | Network | Fetch bounded HTTPS text; optionally build search URLs, resolve configured hosts, and probe configured TCP ports | `network.read` |
35
+ | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `host.inspect` |
36
+ | Processes and commands | Describe configured aliases; optionally inspect allowlisted process names; run one allowlisted executable with `shell=False` | `process.inspect` / `process.execute` |
37
37
 
38
38
  Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, and `error` fields. Paths are always relative to a configured workspace root.
39
39
 
@@ -46,7 +46,7 @@ Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, a
46
46
  - Every workspace mutation and local command requires the SDK approval gate in addition to the orchestration approval policy. File copy accepts regular, non-symlink source files only.
47
47
  - ZIP creation rejects symlinks; ZIP extraction rejects path traversal and symlink entries before writing files.
48
48
  - HTTPS fetching requires an exact host allowlist, rejects redirects, URL credentials, and non-default ports, accepts only text-like content types, and blocks resolved private or loopback addresses. No host is enabled by default. Deployment still needs an egress proxy or firewall: application-layer DNS checks do not replace network isolation.
49
- - Host diagnostics intentionally exclude user identities, process lists, network configuration, installed software, and environment variables except for names explicitly configured by the host.
49
+ - Host diagnostics intentionally exclude user identities, network configuration, installed software, and environment variables except for names explicitly configured by the host. Process diagnostics are separate, disabled by default, and return only configured process names, PIDs, states, and start times.
50
50
  - Command execution accepts an argument array, never a shell string. Programs must be mapped by the host application, execution has a timeout, and output is truncated.
51
51
  - The local command runner is not an OS security sandbox. Production deployment must run the service or runner inside the company-approved container/sandbox with no production secrets and restricted network access.
52
52
  - Approval remains the orchestration layer's responsibility. Only attach `tools.write` or `tools.execute` after the matching approval policy has been validated.
@@ -67,11 +67,15 @@ tools = create_tools(Path("./workspace"))
67
67
  agent = Agent(
68
68
  name="Workspace assistant",
69
69
  instructions="Use workspace tools when needed.",
70
- tools=list(tools.read),
70
+ tools=list(tools.files.read),
71
71
  )
72
72
  ```
73
73
 
74
- `tools.read` is read-only. `tools.write` and `tools.execute` require approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
74
+ Use the narrowest domain selector: `tools.files`, `tools.network`, `tools.host`, or `tools.process`. The v0.3-style `tools.read`, `tools.write`, and `tools.execute` selectors remain available for compatibility, but combine more capabilities. Every `*.write` and `*.execute` tool requires approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
75
+
76
+ ## Design specs
77
+
78
+ - [v0.4 tool expansion spec](docs/v0.4-tool-expansion-spec.md)
75
79
 
76
80
  For configured commands, HTTPS hosts, or readable environment variables, use `ToolConfig`:
77
81
 
@@ -91,6 +95,25 @@ tools = create_tools(
91
95
  )
92
96
  ```
93
97
 
98
+ Network diagnostics and process inspection are disabled until explicitly configured:
99
+
100
+ ```python
101
+ from function_tools import ToolConfig, create_tools
102
+
103
+ tools = create_tools(
104
+ ToolConfig(
105
+ workspace_root="./workspace",
106
+ http_allowed_hosts=frozenset({"api.example.internal"}),
107
+ search_engines={"bing": "https://www.bing.com/search"},
108
+ network_allowed_ports=frozenset({443}),
109
+ process_name_allowlist=frozenset({"python.exe"}),
110
+ )
111
+ )
112
+
113
+ network_tools = tools.network.read
114
+ process_tools = tools.process.inspect
115
+ ```
116
+
94
117
  ## License
95
118
 
96
119
  Apache-2.0. See [LICENSE](LICENSE).
@@ -10,14 +10,14 @@ python -m pip install agents-function-tools
10
10
 
11
11
  ## Included tools
12
12
 
13
- | Category | Tools | Tool group |
13
+ | Category | Tools | Recommended selector |
14
14
  |---|---|---|
15
- | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `read` |
16
- | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `write` |
17
- | ZIP | List archive entries; create and extract bounded ZIP archives | `read` / `write` |
18
- | Network | Fetch bounded HTTPS text from configured hosts | `read` |
19
- | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `read` |
20
- | Commands | Describe configured aliases; run one allowlisted executable with `shell=False` | `read` / `execute` |
15
+ | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `files.read` |
16
+ | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `files.write` |
17
+ | ZIP | List archive entries; create and extract bounded ZIP archives | `files.read` / `files.write` |
18
+ | Network | Fetch bounded HTTPS text; optionally build search URLs, resolve configured hosts, and probe configured TCP ports | `network.read` |
19
+ | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `host.inspect` |
20
+ | Processes and commands | Describe configured aliases; optionally inspect allowlisted process names; run one allowlisted executable with `shell=False` | `process.inspect` / `process.execute` |
21
21
 
22
22
  Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, and `error` fields. Paths are always relative to a configured workspace root.
23
23
 
@@ -30,7 +30,7 @@ Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, a
30
30
  - Every workspace mutation and local command requires the SDK approval gate in addition to the orchestration approval policy. File copy accepts regular, non-symlink source files only.
31
31
  - ZIP creation rejects symlinks; ZIP extraction rejects path traversal and symlink entries before writing files.
32
32
  - HTTPS fetching requires an exact host allowlist, rejects redirects, URL credentials, and non-default ports, accepts only text-like content types, and blocks resolved private or loopback addresses. No host is enabled by default. Deployment still needs an egress proxy or firewall: application-layer DNS checks do not replace network isolation.
33
- - Host diagnostics intentionally exclude user identities, process lists, network configuration, installed software, and environment variables except for names explicitly configured by the host.
33
+ - Host diagnostics intentionally exclude user identities, network configuration, installed software, and environment variables except for names explicitly configured by the host. Process diagnostics are separate, disabled by default, and return only configured process names, PIDs, states, and start times.
34
34
  - Command execution accepts an argument array, never a shell string. Programs must be mapped by the host application, execution has a timeout, and output is truncated.
35
35
  - The local command runner is not an OS security sandbox. Production deployment must run the service or runner inside the company-approved container/sandbox with no production secrets and restricted network access.
36
36
  - Approval remains the orchestration layer's responsibility. Only attach `tools.write` or `tools.execute` after the matching approval policy has been validated.
@@ -51,11 +51,15 @@ tools = create_tools(Path("./workspace"))
51
51
  agent = Agent(
52
52
  name="Workspace assistant",
53
53
  instructions="Use workspace tools when needed.",
54
- tools=list(tools.read),
54
+ tools=list(tools.files.read),
55
55
  )
56
56
  ```
57
57
 
58
- `tools.read` is read-only. `tools.write` and `tools.execute` require approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
58
+ Use the narrowest domain selector: `tools.files`, `tools.network`, `tools.host`, or `tools.process`. The v0.3-style `tools.read`, `tools.write`, and `tools.execute` selectors remain available for compatibility, but combine more capabilities. Every `*.write` and `*.execute` tool requires approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
59
+
60
+ ## Design specs
61
+
62
+ - [v0.4 tool expansion spec](docs/v0.4-tool-expansion-spec.md)
59
63
 
60
64
  For configured commands, HTTPS hosts, or readable environment variables, use `ToolConfig`:
61
65
 
@@ -75,6 +79,25 @@ tools = create_tools(
75
79
  )
76
80
  ```
77
81
 
82
+ Network diagnostics and process inspection are disabled until explicitly configured:
83
+
84
+ ```python
85
+ from function_tools import ToolConfig, create_tools
86
+
87
+ tools = create_tools(
88
+ ToolConfig(
89
+ workspace_root="./workspace",
90
+ http_allowed_hosts=frozenset({"api.example.internal"}),
91
+ search_engines={"bing": "https://www.bing.com/search"},
92
+ network_allowed_ports=frozenset({443}),
93
+ process_name_allowlist=frozenset({"python.exe"}),
94
+ )
95
+ )
96
+
97
+ network_tools = tools.network.read
98
+ process_tools = tools.process.inspect
99
+ ```
100
+
78
101
  ## License
79
102
 
80
103
  Apache-2.0. See [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agents-function-tools"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Portable, policy-friendly system function tools for AI applications."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agents-function-tools
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Portable, policy-friendly system function tools for AI applications.
5
5
  License-Expression: Apache-2.0
6
6
  Classifier: Programming Language :: Python :: 3
@@ -26,14 +26,14 @@ python -m pip install agents-function-tools
26
26
 
27
27
  ## Included tools
28
28
 
29
- | Category | Tools | Tool group |
29
+ | Category | Tools | Recommended selector |
30
30
  |---|---|---|
31
- | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `read` |
32
- | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `write` |
33
- | ZIP | List archive entries; create and extract bounded ZIP archives | `read` / `write` |
34
- | Network | Fetch bounded HTTPS text from configured hosts | `read` |
35
- | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `read` |
36
- | Commands | Describe configured aliases; run one allowlisted executable with `shell=False` | `read` / `execute` |
31
+ | Workspace read | List, read UTF-8, inspect metadata, glob-style find, hash files, disk usage | `files.read` |
32
+ | Workspace write | Write text, create directories, copy a regular file, move a path, delete a path | `files.write` |
33
+ | ZIP | List archive entries; create and extract bounded ZIP archives | `files.read` / `files.write` |
34
+ | Network | Fetch bounded HTTPS text; optionally build search URLs, resolve configured hosts, and probe configured TCP ports | `network.read` |
35
+ | Host | Non-sensitive system info, UTC time, explicitly allowlisted environment variables | `host.inspect` |
36
+ | Processes and commands | Describe configured aliases; optionally inspect allowlisted process names; run one allowlisted executable with `shell=False` | `process.inspect` / `process.execute` |
37
37
 
38
38
  Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, and `error` fields. Paths are always relative to a configured workspace root.
39
39
 
@@ -46,7 +46,7 @@ Every tool returns the same JSON envelope with `ok`, `tool`, `effect`, `data`, a
46
46
  - Every workspace mutation and local command requires the SDK approval gate in addition to the orchestration approval policy. File copy accepts regular, non-symlink source files only.
47
47
  - ZIP creation rejects symlinks; ZIP extraction rejects path traversal and symlink entries before writing files.
48
48
  - HTTPS fetching requires an exact host allowlist, rejects redirects, URL credentials, and non-default ports, accepts only text-like content types, and blocks resolved private or loopback addresses. No host is enabled by default. Deployment still needs an egress proxy or firewall: application-layer DNS checks do not replace network isolation.
49
- - Host diagnostics intentionally exclude user identities, process lists, network configuration, installed software, and environment variables except for names explicitly configured by the host.
49
+ - Host diagnostics intentionally exclude user identities, network configuration, installed software, and environment variables except for names explicitly configured by the host. Process diagnostics are separate, disabled by default, and return only configured process names, PIDs, states, and start times.
50
50
  - Command execution accepts an argument array, never a shell string. Programs must be mapped by the host application, execution has a timeout, and output is truncated.
51
51
  - The local command runner is not an OS security sandbox. Production deployment must run the service or runner inside the company-approved container/sandbox with no production secrets and restricted network access.
52
52
  - Approval remains the orchestration layer's responsibility. Only attach `tools.write` or `tools.execute` after the matching approval policy has been validated.
@@ -67,11 +67,15 @@ tools = create_tools(Path("./workspace"))
67
67
  agent = Agent(
68
68
  name="Workspace assistant",
69
69
  instructions="Use workspace tools when needed.",
70
- tools=list(tools.read),
70
+ tools=list(tools.files.read),
71
71
  )
72
72
  ```
73
73
 
74
- `tools.read` is read-only. `tools.write` and `tools.execute` require approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
74
+ Use the narrowest domain selector: `tools.files`, `tools.network`, `tools.host`, or `tools.process`. The v0.3-style `tools.read`, `tools.write`, and `tools.execute` selectors remain available for compatibility, but combine more capabilities. Every `*.write` and `*.execute` tool requires approval on every call. The SDK derives each FunctionTool's input schema from the Python signature and docstring.
75
+
76
+ ## Design specs
77
+
78
+ - [v0.4 tool expansion spec](docs/v0.4-tool-expansion-spec.md)
75
79
 
76
80
  For configured commands, HTTPS hosts, or readable environment variables, use `ToolConfig`:
77
81
 
@@ -91,6 +95,25 @@ tools = create_tools(
91
95
  )
92
96
  ```
93
97
 
98
+ Network diagnostics and process inspection are disabled until explicitly configured:
99
+
100
+ ```python
101
+ from function_tools import ToolConfig, create_tools
102
+
103
+ tools = create_tools(
104
+ ToolConfig(
105
+ workspace_root="./workspace",
106
+ http_allowed_hosts=frozenset({"api.example.internal"}),
107
+ search_engines={"bing": "https://www.bing.com/search"},
108
+ network_allowed_ports=frozenset({443}),
109
+ process_name_allowlist=frozenset({"python.exe"}),
110
+ )
111
+ )
112
+
113
+ network_tools = tools.network.read
114
+ process_tools = tools.process.inspect
115
+ ```
116
+
94
117
  ## License
95
118
 
96
119
  Apache-2.0. See [LICENSE](LICENSE).
@@ -12,12 +12,15 @@ src/function_tools/command.py
12
12
  src/function_tools/errors.py
13
13
  src/function_tools/host.py
14
14
  src/function_tools/http.py
15
+ src/function_tools/network.py
15
16
  src/function_tools/openai_tools.py
17
+ src/function_tools/process.py
16
18
  src/function_tools/responses.py
17
19
  src/function_tools/workspace.py
18
20
  tests/test_archive.py
19
21
  tests/test_command.py
20
22
  tests/test_host_and_http.py
23
+ tests/test_network_and_process.py
21
24
  tests/test_openai_tools.py
22
25
  tests/test_public_api.py
23
26
  tests/test_workspace.py
@@ -0,0 +1,52 @@
1
+ """Portable, policy-friendly system function tools for AI applications."""
2
+
3
+ from .archive import ZipArchive
4
+ from .command import CommandPolicy, LocalCommandRunner
5
+ from .errors import FoundationToolError
6
+ from .host import HostInspector
7
+ from .http import HttpPolicy, HttpTextClient
8
+ from .network import NetworkInspector, NetworkPolicy
9
+ from .openai_tools import (
10
+ FilesystemToolGroup,
11
+ FunctionToolBundle,
12
+ HostToolGroup,
13
+ NetworkToolGroup,
14
+ ProcessToolGroup,
15
+ ToolConfig,
16
+ create_filesystem_tools,
17
+ create_function_tools,
18
+ create_host_tools,
19
+ create_network_tools,
20
+ create_process_tools,
21
+ create_tools,
22
+ )
23
+ from .process import ProcessInspector
24
+ from .workspace import Workspace
25
+
26
+ __version__ = "0.4.0"
27
+
28
+ __all__ = [
29
+ "CommandPolicy",
30
+ "FoundationToolError",
31
+ "FilesystemToolGroup",
32
+ "FunctionToolBundle",
33
+ "HostInspector",
34
+ "HostToolGroup",
35
+ "HttpPolicy",
36
+ "HttpTextClient",
37
+ "LocalCommandRunner",
38
+ "NetworkInspector",
39
+ "NetworkPolicy",
40
+ "NetworkToolGroup",
41
+ "ProcessInspector",
42
+ "ProcessToolGroup",
43
+ "ToolConfig",
44
+ "Workspace",
45
+ "ZipArchive",
46
+ "create_filesystem_tools",
47
+ "create_function_tools",
48
+ "create_host_tools",
49
+ "create_network_tools",
50
+ "create_process_tools",
51
+ "create_tools",
52
+ ]
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import ipaddress
4
- import socket
5
3
  import ssl
6
4
  from dataclasses import dataclass
7
5
  from typing import Any
@@ -10,6 +8,7 @@ from urllib.parse import urlparse
10
8
  from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
11
9
 
12
10
  from .errors import FoundationToolError
11
+ from .network import NetworkPolicy
13
12
 
14
13
 
15
14
  @dataclass(frozen=True)
@@ -36,6 +35,11 @@ class HttpTextClient:
36
35
 
37
36
  def __init__(self, policy: HttpPolicy) -> None:
38
37
  self.policy = policy
38
+ self._network_policy = NetworkPolicy(
39
+ allowed_hosts=policy.allowed_hosts,
40
+ timeout_seconds=policy.timeout_seconds,
41
+ allow_private_addresses=policy.allow_private_addresses,
42
+ )
39
43
 
40
44
  def fetch(self, url: str) -> dict[str, Any]:
41
45
  parsed = self._validate_url(url)
@@ -43,7 +47,7 @@ class HttpTextClient:
43
47
  request = Request(
44
48
  url,
45
49
  headers={
46
- "User-Agent": "agents-function-tools/0.3.0",
50
+ "User-Agent": "agents-function-tools",
47
51
  "Accept": "text/plain,text/html,application/json",
48
52
  },
49
53
  )
@@ -106,31 +110,8 @@ class HttpTextClient:
106
110
  raise FoundationToolError(
107
111
  "INVALID_URL", "Only HTTPS default-port URLs without user credentials are allowed."
108
112
  )
109
- if host not in self.policy.allowed_hosts:
110
- allowed = ", ".join(sorted(self.policy.allowed_hosts)) or "none"
111
- raise FoundationToolError(
112
- "HOST_NOT_ALLOWED", f"Host is not allowed. Allowed hosts: {allowed}."
113
- )
113
+ self._network_policy.validate_host(host)
114
114
  return parsed
115
115
 
116
116
  def _validate_addresses(self, hostname: str) -> None:
117
- try:
118
- addresses = {
119
- item[4][0] for item in socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
120
- }
121
- except socket.gaierror as error:
122
- raise FoundationToolError(
123
- "DNS_LOOKUP_FAILED", "Allowed host could not be resolved.", retryable=True
124
- ) from error
125
- if not addresses:
126
- raise FoundationToolError(
127
- "DNS_LOOKUP_FAILED", "Allowed host has no resolved addresses."
128
- )
129
- if self.policy.allow_private_addresses:
130
- return
131
- for address in addresses:
132
- ip = ipaddress.ip_address(address)
133
- if not ip.is_global:
134
- raise FoundationToolError(
135
- "PRIVATE_ADDRESS_BLOCKED", "Resolved address is not public."
136
- )
117
+ self._network_policy.resolve_public_addresses(hostname)
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import re
5
+ import socket
6
+ import time
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from types import MappingProxyType
10
+ from typing import Any
11
+ from urllib.parse import urlencode, urlsplit, urlunsplit
12
+
13
+ from .errors import FoundationToolError
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class NetworkPolicy:
18
+ """Host-owned restrictions for outbound network diagnostics."""
19
+
20
+ allowed_hosts: frozenset[str] = frozenset()
21
+ allowed_ports: frozenset[int] = frozenset({443})
22
+ timeout_seconds: int = 15
23
+ search_engines: Mapping[str, str] = field(default_factory=dict)
24
+ allow_private_addresses: bool = False
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.timeout_seconds <= 0:
28
+ raise FoundationToolError("INVALID_LIMIT", "Network timeout limit must be positive.")
29
+ object.__setattr__(
30
+ self,
31
+ "allowed_hosts",
32
+ frozenset(self._normalize_host(host) for host in self.allowed_hosts),
33
+ )
34
+ object.__setattr__(self, "allowed_ports", frozenset(self.allowed_ports))
35
+ for port in self.allowed_ports:
36
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65_535:
37
+ raise FoundationToolError(
38
+ "INVALID_PORT", "Network ports must be between 1 and 65535."
39
+ )
40
+ if not isinstance(self.search_engines, Mapping):
41
+ raise FoundationToolError(
42
+ "INVALID_SEARCH_ENGINE", "Search engines must be a name-to-URL mapping."
43
+ )
44
+ normalized_engines = {
45
+ self._normalize_engine_name(name): url for name, url in self.search_engines.items()
46
+ }
47
+ for name, url in normalized_engines.items():
48
+ self._validate_engine(name, url)
49
+ object.__setattr__(self, "search_engines", MappingProxyType(normalized_engines))
50
+
51
+ def validate_host(self, hostname: str) -> str:
52
+ normalized = self._normalize_host(hostname)
53
+ if normalized not in self.allowed_hosts:
54
+ allowed = ", ".join(sorted(self.allowed_hosts)) or "none"
55
+ raise FoundationToolError(
56
+ "HOST_NOT_ALLOWED", f"Host is not allowed. Allowed hosts: {allowed}."
57
+ )
58
+ return normalized
59
+
60
+ def resolve_public_addresses(self, hostname: str) -> tuple[str, ...]:
61
+ try:
62
+ addresses = {
63
+ item[4][0] for item in socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
64
+ }
65
+ except socket.gaierror as error:
66
+ raise FoundationToolError(
67
+ "DNS_LOOKUP_FAILED", "Allowed host could not be resolved.", retryable=True
68
+ ) from error
69
+ if not addresses:
70
+ raise FoundationToolError(
71
+ "DNS_LOOKUP_FAILED", "Allowed host has no resolved addresses."
72
+ )
73
+ for address in addresses:
74
+ try:
75
+ ip = ipaddress.ip_address(address)
76
+ except ValueError as error:
77
+ raise FoundationToolError(
78
+ "DNS_LOOKUP_FAILED",
79
+ "Allowed host resolved to an invalid address.",
80
+ retryable=True,
81
+ ) from error
82
+ if not self.allow_private_addresses and not ip.is_global:
83
+ raise FoundationToolError(
84
+ "PRIVATE_ADDRESS_BLOCKED", "Resolved address is not public."
85
+ )
86
+ return tuple(sorted(addresses))
87
+
88
+ @staticmethod
89
+ def _normalize_host(hostname: str) -> str:
90
+ if not isinstance(hostname, str):
91
+ raise FoundationToolError("INVALID_HOST", "Network hosts must be strings.")
92
+ normalized = hostname.strip().lower()
93
+ if (
94
+ not normalized
95
+ or len(normalized) > 253
96
+ or any(character in normalized for character in "/\\@?#:")
97
+ or any(character.isspace() for character in normalized)
98
+ ):
99
+ raise FoundationToolError("INVALID_HOST", "Network hosts must be bare hostnames.")
100
+ return normalized
101
+
102
+ @staticmethod
103
+ def _normalize_engine_name(name: str) -> str:
104
+ if not isinstance(name, str):
105
+ raise FoundationToolError(
106
+ "INVALID_SEARCH_ENGINE", "Search engine names must be strings."
107
+ )
108
+ normalized = name.strip().lower()
109
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", normalized):
110
+ raise FoundationToolError(
111
+ "INVALID_SEARCH_ENGINE", "Search engine names must be 1 to 64 characters."
112
+ )
113
+ return normalized
114
+
115
+ @classmethod
116
+ def _validate_engine(cls, name: str, url: str) -> None:
117
+ if not isinstance(url, str):
118
+ raise FoundationToolError(
119
+ "INVALID_SEARCH_ENGINE", "Search engine URLs must be strings."
120
+ )
121
+ parsed = urlsplit(url)
122
+ try:
123
+ port = parsed.port
124
+ except ValueError as error:
125
+ raise FoundationToolError(
126
+ "INVALID_SEARCH_ENGINE", "Search engine URL has an invalid port."
127
+ ) from error
128
+ if (
129
+ parsed.scheme != "https"
130
+ or not parsed.hostname
131
+ or port not in {None, 443}
132
+ or parsed.username
133
+ or parsed.password
134
+ or parsed.query
135
+ or parsed.fragment
136
+ ):
137
+ raise FoundationToolError(
138
+ "INVALID_SEARCH_ENGINE",
139
+ "Search engine URLs must be HTTPS default-port endpoints without query parameters.",
140
+ )
141
+ cls._normalize_host(parsed.hostname)
142
+
143
+
144
+ class NetworkInspector:
145
+ """Bounded read-only diagnostics for explicitly configured public hosts."""
146
+
147
+ def __init__(self, policy: NetworkPolicy) -> None:
148
+ self.policy = policy
149
+
150
+ def build_search_url(self, engine: str, query: str) -> dict[str, Any]:
151
+ if not isinstance(engine, str) or not engine.strip():
152
+ raise FoundationToolError("INVALID_SEARCH_ENGINE", "Search engine name is required.")
153
+ if not isinstance(query, str) or not query.strip():
154
+ raise FoundationToolError("INVALID_SEARCH_QUERY", "Search query is required.")
155
+ if len(query) > 2_048:
156
+ raise FoundationToolError(
157
+ "SEARCH_QUERY_TOO_LONG", "Search query cannot exceed 2048 characters."
158
+ )
159
+
160
+ name = self.policy._normalize_engine_name(engine)
161
+ base_url = self.policy.search_engines.get(name)
162
+ if base_url is None:
163
+ allowed = ", ".join(sorted(self.policy.search_engines)) or "none"
164
+ raise FoundationToolError(
165
+ "SEARCH_ENGINE_NOT_ALLOWED",
166
+ f"Search engine is not allowed. Allowed engines: {allowed}.",
167
+ )
168
+ parsed = urlsplit(base_url)
169
+ url = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode({"q": query}), ""))
170
+ return {"engine": name, "query": query, "url": url}
171
+
172
+ def dns_lookup(self, hostname: str) -> dict[str, Any]:
173
+ host = self.policy.validate_host(hostname)
174
+ return {"host": host, "addresses": list(self.policy.resolve_public_addresses(host))}
175
+
176
+ def tcp_probe(self, hostname: str, port: int = 443) -> dict[str, Any]:
177
+ host = self.policy.validate_host(hostname)
178
+ if (
179
+ isinstance(port, bool)
180
+ or not isinstance(port, int)
181
+ or port not in self.policy.allowed_ports
182
+ ):
183
+ allowed = ", ".join(str(value) for value in sorted(self.policy.allowed_ports)) or "none"
184
+ raise FoundationToolError(
185
+ "PORT_NOT_ALLOWED", f"Port is not allowed. Allowed ports: {allowed}."
186
+ )
187
+
188
+ addresses = self.policy.resolve_public_addresses(host)
189
+ for address in addresses:
190
+ started = time.monotonic()
191
+ try:
192
+ with socket.create_connection((address, port), timeout=self.policy.timeout_seconds):
193
+ latency_ms = round((time.monotonic() - started) * 1_000, 3)
194
+ return {
195
+ "host": host,
196
+ "port": port,
197
+ "reachable": True,
198
+ "address": address,
199
+ "latency_ms": latency_ms,
200
+ }
201
+ except OSError:
202
+ continue
203
+ return {
204
+ "host": host,
205
+ "port": port,
206
+ "reachable": False,
207
+ "address": None,
208
+ "latency_ms": None,
209
+ }