toolclad 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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolclad
3
+ Version: 0.4.0
4
+ Summary: Reference implementation of the ToolClad declarative tool interface executor
5
+ Author: ThirdKey AI
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: tomli>=1.1.0; python_version < "3.11"
9
+ Requires-Dist: click>=8.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.0; extra == "dev"
12
+ Requires-Dist: pytest-cov; extra == "dev"
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "setuptools-scm"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "toolclad"
7
+ version = "0.4.0"
8
+ description = "Reference implementation of the ToolClad declarative tool interface executor"
9
+ requires-python = ">=3.9"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "ThirdKey AI"},
13
+ ]
14
+ dependencies = [
15
+ "tomli>=1.1.0; python_version < '3.11'",
16
+ "click>=8.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=7.0",
22
+ "pytest-cov",
23
+ ]
24
+
25
+ [project.scripts]
26
+ toolclad = "toolclad.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,270 @@
1
+ """Tests for ToolClad command template interpolation and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Optional
6
+
7
+ import pytest
8
+
9
+ from toolclad.executor import build_command, execute, _evaluate_condition
10
+ from toolclad.manifest import (
11
+ ArgDef,
12
+ CommandDef,
13
+ ConditionalDef,
14
+ EvidenceDef,
15
+ Manifest,
16
+ OutputDef,
17
+ ToolMeta,
18
+ )
19
+ from toolclad.validator import ValidationError
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _simple_manifest(
27
+ template: str = "echo {target}",
28
+ args: Optional[Dict[str, ArgDef]] = None,
29
+ mappings: Optional[Dict[str, Dict[str, str]]] = None,
30
+ conditionals: Optional[Dict[str, dict]] = None,
31
+ defaults: Optional[Dict[str, str]] = None,
32
+ ) -> Manifest:
33
+ """Build a minimal manifest for testing."""
34
+ if args is None:
35
+ args = {
36
+ "target": ArgDef(
37
+ name="target", position=1, required=True, type="string"
38
+ ),
39
+ }
40
+ cmd_conditionals = {}
41
+ if conditionals:
42
+ for k, v in conditionals.items():
43
+ cmd_conditionals[k] = ConditionalDef(name=k, when=v["when"], template=v["template"])
44
+ return Manifest(
45
+ tool=ToolMeta(name="test_tool", version="1.0.0", binary="echo"),
46
+ args=args,
47
+ command=CommandDef(
48
+ template=template,
49
+ mappings=mappings or {},
50
+ conditionals=cmd_conditionals,
51
+ defaults=defaults or {},
52
+ ),
53
+ output=OutputDef(format="text"),
54
+ )
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Basic interpolation
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class TestBasicInterpolation:
62
+ def test_single_arg(self):
63
+ m = _simple_manifest("echo {target}")
64
+ result = build_command(m, {"target": "hello"})
65
+ assert result == "echo hello"
66
+
67
+ def test_multiple_args(self):
68
+ m = _simple_manifest(
69
+ "tool --host {host} --port {port}",
70
+ args={
71
+ "host": ArgDef(name="host", position=1, required=True, type="string"),
72
+ "port": ArgDef(name="port", position=2, required=True, type="port"),
73
+ },
74
+ )
75
+ result = build_command(m, {"host": "example.com", "port": "8080"})
76
+ assert result == "tool --host example.com --port 8080"
77
+
78
+ def test_default_value_used(self):
79
+ m = _simple_manifest(
80
+ "tool --rate {max_rate} {target}",
81
+ args={
82
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
83
+ },
84
+ defaults={"max_rate": "1000"},
85
+ )
86
+ result = build_command(m, {"target": "10.0.1.1"})
87
+ assert result == "tool --rate 1000 10.0.1.1"
88
+
89
+ def test_optional_arg_defaults_to_empty(self):
90
+ m = _simple_manifest(
91
+ "tool {flags} {target}",
92
+ args={
93
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
94
+ "flags": ArgDef(name="flags", position=2, required=False, type="string", default=None),
95
+ },
96
+ )
97
+ result = build_command(m, {"target": "10.0.1.1"})
98
+ assert result == "tool 10.0.1.1"
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Mappings
103
+ # ---------------------------------------------------------------------------
104
+
105
+ class TestMappings:
106
+ def test_scan_type_mapping(self):
107
+ m = _simple_manifest(
108
+ "nmap {_scan_flags} {target}",
109
+ args={
110
+ "target": ArgDef(name="target", position=1, required=True, type="scope_target"),
111
+ "scan_type": ArgDef(
112
+ name="scan_type", position=2, required=True,
113
+ type="enum", allowed=["ping", "service", "syn"],
114
+ ),
115
+ },
116
+ mappings={
117
+ "scan_type": {
118
+ "ping": "-sn -PE",
119
+ "service": "-sT -sV --version-intensity 5",
120
+ "syn": "-sS --top-ports 1000",
121
+ },
122
+ },
123
+ )
124
+ result = build_command(m, {"target": "10.0.1.0/24", "scan_type": "service"})
125
+ assert result == "nmap -sT -sV --version-intensity 5 10.0.1.0/24"
126
+
127
+ def test_mapping_unknown_value_empty(self):
128
+ """A mapping value not in the table resolves to empty string."""
129
+ m = _simple_manifest(
130
+ "tool {_mode_flags} {target}",
131
+ args={
132
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
133
+ "mode": ArgDef(
134
+ name="mode", position=2, required=True,
135
+ type="enum", allowed=["a", "b", "c"],
136
+ ),
137
+ },
138
+ mappings={"mode": {"a": "--alpha", "b": "--beta"}},
139
+ )
140
+ result = build_command(m, {"target": "x", "mode": "c"})
141
+ # c has no mapping, so {_mode_flags} resolves to ""
142
+ assert result == "tool x"
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Conditionals
147
+ # ---------------------------------------------------------------------------
148
+
149
+ class TestConditionals:
150
+ def test_conditional_included_when_true(self):
151
+ m = _simple_manifest(
152
+ "tool {_port_flag} {target}",
153
+ args={
154
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
155
+ "port": ArgDef(name="port", position=2, required=False, type="port", default="8080"),
156
+ },
157
+ conditionals={
158
+ "port_flag": {"when": "port != 0", "template": "-p {port}"},
159
+ },
160
+ )
161
+ result = build_command(m, {"target": "example.com", "port": "443"})
162
+ assert result == "tool -p 443 example.com"
163
+
164
+ def test_conditional_excluded_when_false(self):
165
+ m = _simple_manifest(
166
+ "tool {_port_flag} {target}",
167
+ args={
168
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
169
+ "port": ArgDef(name="port", position=2, required=False, type="port", default="0"),
170
+ },
171
+ conditionals={
172
+ "port_flag": {"when": "port != 0", "template": "-p {port}"},
173
+ },
174
+ )
175
+ # port == 0, so conditional is false -> fragment excluded
176
+ result = build_command(m, {"target": "example.com"})
177
+ assert result == "tool example.com"
178
+
179
+ def test_conditional_with_empty_string_check(self):
180
+ m = _simple_manifest(
181
+ "tool {_user_flag} {target}",
182
+ args={
183
+ "target": ArgDef(name="target", position=1, required=True, type="string"),
184
+ "username": ArgDef(name="username", position=2, required=False, type="string"),
185
+ },
186
+ conditionals={
187
+ "user_flag": {"when": "username != ''", "template": "-l {username}"},
188
+ },
189
+ )
190
+ # username not provided -> empty string -> conditional false
191
+ result = build_command(m, {"target": "10.0.1.1"})
192
+ assert result == "tool 10.0.1.1"
193
+
194
+ # username provided
195
+ result = build_command(m, {"target": "10.0.1.1", "username": "admin"})
196
+ assert result == "tool -l admin 10.0.1.1"
197
+
198
+ def test_compound_and_condition(self):
199
+ resolved = {"a": "1", "b": "2"}
200
+ assert _evaluate_condition("a != '' and b != ''", resolved) is True
201
+ assert _evaluate_condition("a != '' and b == ''", resolved) is False
202
+
203
+ def test_compound_or_condition(self):
204
+ resolved = {"a": "", "b": "2"}
205
+ assert _evaluate_condition("a != '' or b != ''", resolved) is True
206
+ assert _evaluate_condition("a != '' or b == 'x'", resolved) is False
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Validation integration
211
+ # ---------------------------------------------------------------------------
212
+
213
+ class TestValidationInBuild:
214
+ def test_missing_required_arg(self):
215
+ m = _simple_manifest("echo {target}")
216
+ with pytest.raises(ValidationError, match="Missing required"):
217
+ build_command(m, {})
218
+
219
+ def test_invalid_arg_value(self):
220
+ m = _simple_manifest(
221
+ "tool -p {port}",
222
+ args={
223
+ "port": ArgDef(name="port", position=1, required=True, type="port"),
224
+ },
225
+ )
226
+ with pytest.raises(ValidationError, match="out of range"):
227
+ build_command(m, {"port": "99999"})
228
+
229
+ def test_injection_blocked(self):
230
+ m = _simple_manifest("echo {target}")
231
+ with pytest.raises(ValidationError, match="shell metacharacters"):
232
+ build_command(m, {"target": "hello; rm -rf /"})
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Executor escape hatch
237
+ # ---------------------------------------------------------------------------
238
+
239
+ class TestExecutorEscapeHatch:
240
+ def test_executor_manifest_raises(self):
241
+ m = Manifest(
242
+ tool=ToolMeta(name="msf", version="1.0.0", binary="msfconsole"),
243
+ command=CommandDef(executor="scripts/msf-wrapper.sh"),
244
+ )
245
+ with pytest.raises(ValueError, match="custom executor"):
246
+ build_command(m, {})
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Dry-run execution
251
+ # ---------------------------------------------------------------------------
252
+
253
+ class TestDryRun:
254
+ def test_dry_run_returns_envelope(self):
255
+ m = _simple_manifest("echo {target}")
256
+ envelope = execute(m, {"target": "hello"}, dry_run=True)
257
+ assert envelope["status"] == "dry_run"
258
+ assert envelope["tool"] == "test_tool"
259
+ assert "echo hello" in envelope["command"]
260
+ assert envelope["duration_ms"] == 0
261
+
262
+ def test_dry_run_has_envelope_keys(self):
263
+ m = _simple_manifest("echo {target}")
264
+ envelope = execute(m, {"target": "hello"}, dry_run=True)
265
+ expected_keys = {
266
+ "status", "scan_id", "tool", "command",
267
+ "duration_ms", "timestamp", "output_file",
268
+ "output_hash", "results",
269
+ }
270
+ assert expected_keys == set(envelope.keys())
@@ -0,0 +1,271 @@
1
+ """Tests for ToolClad argument type validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from toolclad.manifest import ArgDef
8
+ from toolclad.validator import ValidationError, validate_arg
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Helpers
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def _arg(type: str, **kwargs) -> ArgDef: # noqa: A002
16
+ """Create an ArgDef with the given type and optional overrides."""
17
+ return ArgDef(name="test", type=type, **kwargs)
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # string
22
+ # ---------------------------------------------------------------------------
23
+
24
+ class TestStringValidation:
25
+ def test_valid_string(self):
26
+ assert validate_arg(_arg("string"), "hello") == "hello"
27
+
28
+ def test_string_with_pattern(self):
29
+ ad = _arg("string", pattern=r"^[a-z]+$")
30
+ assert validate_arg(ad, "abc") == "abc"
31
+
32
+ def test_string_pattern_mismatch(self):
33
+ ad = _arg("string", pattern=r"^[a-z]+$")
34
+ with pytest.raises(ValidationError, match="does not match pattern"):
35
+ validate_arg(ad, "ABC123")
36
+
37
+ def test_string_injection_rejected(self):
38
+ with pytest.raises(ValidationError, match="shell metacharacters"):
39
+ validate_arg(_arg("string"), "hello; rm -rf /")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # integer
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class TestIntegerValidation:
47
+ def test_valid_integer(self):
48
+ assert validate_arg(_arg("integer"), "42") == "42"
49
+
50
+ def test_negative_integer(self):
51
+ assert validate_arg(_arg("integer"), "-5") == "-5"
52
+
53
+ def test_not_a_number(self):
54
+ with pytest.raises(ValidationError, match="Expected integer"):
55
+ validate_arg(_arg("integer"), "abc")
56
+
57
+ def test_min_max_in_range(self):
58
+ ad = _arg("integer", min=1, max=100)
59
+ assert validate_arg(ad, "50") == "50"
60
+
61
+ def test_below_min(self):
62
+ ad = _arg("integer", min=1, max=100)
63
+ with pytest.raises(ValidationError, match="below minimum"):
64
+ validate_arg(ad, "0")
65
+
66
+ def test_above_max(self):
67
+ ad = _arg("integer", min=1, max=100)
68
+ with pytest.raises(ValidationError, match="above maximum"):
69
+ validate_arg(ad, "200")
70
+
71
+ def test_clamp_below(self):
72
+ ad = _arg("integer", min=1, max=100, clamp=True)
73
+ assert validate_arg(ad, "-5") == "1"
74
+
75
+ def test_clamp_above(self):
76
+ ad = _arg("integer", min=1, max=100, clamp=True)
77
+ assert validate_arg(ad, "999") == "100"
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # port
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class TestPortValidation:
85
+ def test_valid_port(self):
86
+ assert validate_arg(_arg("port"), "443") == "443"
87
+
88
+ def test_port_zero(self):
89
+ assert validate_arg(_arg("port"), "0") == "0"
90
+
91
+ def test_port_max(self):
92
+ assert validate_arg(_arg("port"), "65535") == "65535"
93
+
94
+ def test_port_out_of_range(self):
95
+ with pytest.raises(ValidationError, match="out of range"):
96
+ validate_arg(_arg("port"), "70000")
97
+
98
+ def test_port_negative(self):
99
+ with pytest.raises(ValidationError, match="out of range"):
100
+ validate_arg(_arg("port"), "-1")
101
+
102
+ def test_port_not_a_number(self):
103
+ with pytest.raises(ValidationError, match="Expected port"):
104
+ validate_arg(_arg("port"), "http")
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # boolean
109
+ # ---------------------------------------------------------------------------
110
+
111
+ class TestBooleanValidation:
112
+ def test_true(self):
113
+ assert validate_arg(_arg("boolean"), "true") == "true"
114
+
115
+ def test_false(self):
116
+ assert validate_arg(_arg("boolean"), "false") == "false"
117
+
118
+ def test_case_insensitive(self):
119
+ assert validate_arg(_arg("boolean"), "True") == "true"
120
+ assert validate_arg(_arg("boolean"), "FALSE") == "false"
121
+
122
+ def test_invalid_boolean(self):
123
+ with pytest.raises(ValidationError, match="Expected 'true' or 'false'"):
124
+ validate_arg(_arg("boolean"), "yes")
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # enum
129
+ # ---------------------------------------------------------------------------
130
+
131
+ class TestEnumValidation:
132
+ def test_valid_enum(self):
133
+ ad = _arg("enum", allowed=["ping", "service", "syn"])
134
+ assert validate_arg(ad, "service") == "service"
135
+
136
+ def test_invalid_enum(self):
137
+ ad = _arg("enum", allowed=["ping", "service", "syn"])
138
+ with pytest.raises(ValidationError, match="not in allowed"):
139
+ validate_arg(ad, "aggressive")
140
+
141
+ def test_enum_no_allowed_list(self):
142
+ ad = _arg("enum")
143
+ with pytest.raises(ValidationError, match="requires 'allowed' list"):
144
+ validate_arg(ad, "anything")
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # scope_target
149
+ # ---------------------------------------------------------------------------
150
+
151
+ class TestScopeTargetValidation:
152
+ def test_valid_ip(self):
153
+ assert validate_arg(_arg("scope_target"), "10.0.1.1") == "10.0.1.1"
154
+
155
+ def test_valid_cidr(self):
156
+ assert validate_arg(_arg("scope_target"), "10.0.1.0/24") == "10.0.1.0/24"
157
+
158
+ def test_valid_hostname(self):
159
+ assert validate_arg(_arg("scope_target"), "example.com") == "example.com"
160
+
161
+ def test_wildcard_rejected(self):
162
+ with pytest.raises(ValidationError, match="Wildcard"):
163
+ validate_arg(_arg("scope_target"), "*.example.com")
164
+
165
+ def test_injection_in_target(self):
166
+ with pytest.raises(ValidationError, match="shell metacharacters"):
167
+ validate_arg(_arg("scope_target"), "10.0.1.1; echo pwned")
168
+
169
+ def test_invalid_target(self):
170
+ with pytest.raises(ValidationError, match="Invalid scope target"):
171
+ validate_arg(_arg("scope_target"), "not a valid target at all!!!")
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # url
176
+ # ---------------------------------------------------------------------------
177
+
178
+ class TestUrlValidation:
179
+ def test_valid_http_url(self):
180
+ ad = _arg("url")
181
+ assert validate_arg(ad, "https://example.com/path") == "https://example.com/path"
182
+
183
+ def test_scheme_restriction(self):
184
+ ad = _arg("url", schemes=["https"])
185
+ with pytest.raises(ValidationError, match="scheme"):
186
+ validate_arg(ad, "http://example.com")
187
+
188
+ def test_invalid_url(self):
189
+ with pytest.raises(ValidationError, match="Invalid URL"):
190
+ validate_arg(_arg("url"), "not-a-url")
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # path
195
+ # ---------------------------------------------------------------------------
196
+
197
+ class TestPathValidation:
198
+ def test_valid_path(self):
199
+ assert validate_arg(_arg("path"), "/usr/share/wordlists/common.txt") == "/usr/share/wordlists/common.txt"
200
+
201
+ def test_traversal_rejected(self):
202
+ with pytest.raises(ValidationError, match="Path traversal"):
203
+ validate_arg(_arg("path"), "/etc/../../../etc/shadow")
204
+
205
+ def test_injection_in_path(self):
206
+ with pytest.raises(ValidationError, match="shell metacharacters"):
207
+ validate_arg(_arg("path"), "/tmp/$(whoami).txt")
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # ip_address
212
+ # ---------------------------------------------------------------------------
213
+
214
+ class TestIpAddressValidation:
215
+ def test_valid_ipv4(self):
216
+ assert validate_arg(_arg("ip_address"), "192.168.1.1") == "192.168.1.1"
217
+
218
+ def test_valid_ipv6(self):
219
+ assert validate_arg(_arg("ip_address"), "::1") == "::1"
220
+
221
+ def test_invalid_ip(self):
222
+ with pytest.raises(ValidationError, match="Invalid IP address"):
223
+ validate_arg(_arg("ip_address"), "999.999.999.999")
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # cidr
228
+ # ---------------------------------------------------------------------------
229
+
230
+ class TestCidrValidation:
231
+ def test_valid_cidr(self):
232
+ assert validate_arg(_arg("cidr"), "10.0.0.0/8") == "10.0.0.0/8"
233
+
234
+ def test_missing_slash(self):
235
+ with pytest.raises(ValidationError, match="requires '/'"):
236
+ validate_arg(_arg("cidr"), "10.0.0.1")
237
+
238
+ def test_invalid_cidr(self):
239
+ with pytest.raises(ValidationError, match="Invalid CIDR"):
240
+ validate_arg(_arg("cidr"), "not/a/cidr")
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Injection sanitization
245
+ # ---------------------------------------------------------------------------
246
+
247
+ class TestInjectionSanitization:
248
+ """Shell metacharacter rejection applies to string-based types."""
249
+
250
+ @pytest.mark.parametrize("char", list(";|&$`(){}[]<>!"))
251
+ def test_metacharacter_rejected_in_string(self, char):
252
+ with pytest.raises(ValidationError, match="shell metacharacters"):
253
+ validate_arg(_arg("string"), f"value{char}injection")
254
+
255
+ def test_semicolon_in_scope_target(self):
256
+ with pytest.raises(ValidationError, match="shell metacharacters"):
257
+ validate_arg(_arg("scope_target"), "10.0.1.1;echo")
258
+
259
+ def test_backtick_in_path(self):
260
+ with pytest.raises(ValidationError, match="shell metacharacters"):
261
+ validate_arg(_arg("path"), "/tmp/`whoami`")
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Unknown type
266
+ # ---------------------------------------------------------------------------
267
+
268
+ class TestUnknownType:
269
+ def test_unknown_type_raises(self):
270
+ with pytest.raises(ValidationError, match="Unknown type"):
271
+ validate_arg(_arg("foobar"), "anything")
@@ -0,0 +1,15 @@
1
+ """ToolClad: Declarative Tool Interface Contracts for Agentic Runtimes."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from toolclad.manifest import Manifest, load_manifest
6
+ from toolclad.validator import validate_arg
7
+ from toolclad.executor import build_command, execute
8
+
9
+ __all__ = [
10
+ "Manifest",
11
+ "load_manifest",
12
+ "validate_arg",
13
+ "build_command",
14
+ "execute",
15
+ ]