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.
- toolclad-0.4.0/PKG-INFO +12 -0
- toolclad-0.4.0/pyproject.toml +32 -0
- toolclad-0.4.0/setup.cfg +4 -0
- toolclad-0.4.0/tests/__init__.py +0 -0
- toolclad-0.4.0/tests/test_executor.py +270 -0
- toolclad-0.4.0/tests/test_validator.py +271 -0
- toolclad-0.4.0/toolclad/__init__.py +15 -0
- toolclad-0.4.0/toolclad/cli.py +191 -0
- toolclad-0.4.0/toolclad/executor.py +269 -0
- toolclad-0.4.0/toolclad/manifest.py +252 -0
- toolclad-0.4.0/toolclad/validator.py +202 -0
- toolclad-0.4.0/toolclad.egg-info/PKG-INFO +12 -0
- toolclad-0.4.0/toolclad.egg-info/SOURCES.txt +15 -0
- toolclad-0.4.0/toolclad.egg-info/dependency_links.txt +1 -0
- toolclad-0.4.0/toolclad.egg-info/entry_points.txt +2 -0
- toolclad-0.4.0/toolclad.egg-info/requires.txt +8 -0
- toolclad-0.4.0/toolclad.egg-info/top_level.txt +3 -0
toolclad-0.4.0/PKG-INFO
ADDED
|
@@ -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"]
|
toolclad-0.4.0/setup.cfg
ADDED
|
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
|
+
]
|