The Problem: Unconstrained Tool Execution
Agentic runtimes let LLMs invoke CLI tools, but without constraints the model can generate arbitrary shell commands. Deny-list approaches are fragile — one missed metacharacter and you have command injection.
ToolClad inverts the model: instead of blocking dangerous commands, it constrains the LLM to fill typed parameters validated against a declarative manifest (allow-list). The dangerous action cannot be expressed because the interface doesn't permit it.
- Typed parameters with automatic validation (string, integer, port, enum, scope_target, url, path, ip_address, cidr, boolean)
- Shell metacharacter rejection by default on all string types
- Command template rendering with value mappings
- Evidence envelopes with execution metadata and output hashing
- MCP schema generation from manifests
Step 1: Add ToolClad to Your Project
Add the toolclad crate to your Cargo.toml:
[dependencies]
toolclad = "0.5"
Step 2: Create a Manifest
A .clad.toml manifest defines everything about a tool: its binary, typed arguments, command template, output format, and risk tier. Save this as whois_lookup.clad.toml:
[tool]
name = "whois_lookup"
version = "1.0.0"
binary = "whois"
description = "WHOIS domain/IP registration lookup"
timeout_seconds = 30
risk_tier = "low"
[tool.cedar]
resource = "PenTest::ScanTarget"
action = "execute_tool"
[args.target]
position = 1
required = true
type = "scope_target"
description = "Domain name or IP address to query"
[command]
template = "whois {target}"
[output]
format = "text"
envelope = true
[output.schema]
type = "object"
[output.schema.properties.raw_output]
type = "string"
description = "Raw WHOIS registration data"
The scope_target type automatically validates that the input is a safe hostname, IP address, or CIDR — no wildcards, no shell metacharacters, no traversal sequences.
Step 3: Load and Validate a Manifest
Load a manifest from disk and inspect its contents. ToolClad validates the manifest structure on load — missing templates, invalid risk tiers, and enum args without allowed lists are all caught immediately.
use toolclad::load_manifest;
let manifest = toolclad::load_manifest("tools/whois_lookup.clad.toml").unwrap();
println!("Tool: {} v{}", manifest.tool.name, manifest.tool.version);
println!("Binary: {}", manifest.tool.binary);
println!("Risk tier: {}", manifest.tool.risk_tier);
println!("Timeout: {}s", manifest.tool.timeout_seconds);
// Inspect arguments
for (name, arg) in &manifest.args {
println!(" arg '{}': type={}, required={}", name, arg.type_name, arg.required);
}
You can also validate individual arguments against their type definitions:
use toolclad::validator::validate_arg;
// Valid: a clean domain name
let result = validate_arg("target", &manifest.args["target"], "example.com");
assert!(result.is_ok());
// Rejected: shell metacharacters
let result = validate_arg("target", &manifest.args["target"], "example.com; rm -rf /");
assert!(result.is_err());
Step 4: Execute a Tool with Validated Arguments
Execute a tool by passing a map of argument values. ToolClad validates every argument, renders the command template, runs the binary with the configured timeout, and wraps the result in an evidence envelope.
use std::collections::HashMap;
use toolclad::executor::execute;
let manifest = toolclad::load_manifest("tools/whois_lookup.clad.toml").unwrap();
let mut args = HashMap::new();
args.insert("target".to_string(), "example.com".to_string());
let envelope = execute(&manifest, &args).unwrap();
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
The executor constructs the command whois example.com from the template, runs it with a 30-second timeout, and returns a structured envelope.
Step 5: Generate MCP Schema from Manifest
Generate a Model Context Protocol-compatible JSON schema directly from a manifest. This bridges ToolClad manifests to any MCP-compatible agent runtime.
use toolclad::{load_manifest, generate_mcp_schema};
let manifest = load_manifest("tools/whois_lookup.clad.toml").unwrap();
let schema = generate_mcp_schema(&manifest);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
The generated schema includes inputSchema with typed properties and required fields, plus an outputSchema that wraps your declared results in the evidence envelope format when envelope = true.
// Output:
{
"name": "whois_lookup",
"description": "WHOIS domain/IP registration lookup",
"inputSchema": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Domain name or IP address to query"
}
},
"required": ["target"]
},
"outputSchema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["success", "error"] },
"scan_id": { "type": "string" },
"tool": { "type": "string" },
"command": { "type": "string" },
"duration_ms": { "type": "integer" },
"timestamp": { "type": "string", "format": "date-time" },
"output_file": { "type": "string" },
"output_hash": { "type": "string" },
"results": { ... }
}
}
}
Step 6: Use Evidence Envelopes
When envelope = true in the manifest, every execution result is wrapped in an evidence envelope that provides a tamper-evident audit trail:
// Evidence envelope returned by execute()
{
"status": "success",
"scan_id": "a3f1c9e2-7b4d-4e8a-9c6f-1d2e3f4a5b6c",
"tool": "whois_lookup",
"command": "whois example.com",
"duration_ms": 1842,
"timestamp": "2026-03-22T10:15:30Z",
"output_file": "/tmp/toolclad/whois_lookup_a3f1c9e2.txt",
"output_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb924...",
"results": {
"raw_output": "Domain Name: EXAMPLE.COM\nRegistrar: ..."
}
}
Key fields in the envelope:
- scan_id — Unique identifier for this execution
- command — The exact command that was run (for audit)
- duration_ms — Wall-clock execution time
- output_hash — SHA-256 hash of the raw output for tamper detection
- output_file — Path to the raw output on disk
The envelope ensures that downstream consumers (other agents, logging systems, compliance tools) can verify that tool output has not been modified after execution.