diff --git a/Cargo.lock b/Cargo.lock index e061206..346a58a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "ardur-code-execution" +version = "0.0.1" +dependencies = [ + "anyhow", + "ardur-injection-defense", + "ardur-runtime", + "ardur-tool-registry", + "async-trait", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "ardur-config" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index e5ce750..f278ad2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,317 +1,292 @@ -# Workspace root for Ardur. -# -# Phase-0 scaffold (§0.0): a flat `crates/` workspace, one crate per §X.Y -# plan-family. The crates below are contracts-only skeletons — their public -# trait surface is frozen against the owning plan-doc; bodies land in the -# owning plan's Phase 1. See `plans/0.0-workspace-scaffold-blueprint.md`. - [workspace] resolver = "2" -members = [ - "crates/acp", - "crates/approvals", - "crates/core-types", - "crates/cap-token", - "crates/embeddings", - "crates/bm25-index", - "crates/fusion", - "crates/receipt", - "crates/lifecycle-hooks", - "crates/hooks-openclaw-compat", - "crates/cedar-policy", - "crates/memory", - "crates/memory-qdrant", - "crates/runtime", - "crates/automation", - "crates/cron", - "crates/cron-ui", - "crates/standing-goals", - "crates/cost-gate", - "crates/provider-runtime", - "crates/media-decode", - "crates/media-audio", - "crates/media-video", - "crates/provider-openrouter", - "crates/provider-openai-compat", - "crates/provider-ollama", - "crates/provider-codex", - "crates/provider-claude-cli", - "crates/provider-selector", - "crates/cli", - "crates/messaging-gateway", - "crates/tool-registry", - "crates/session-journals", - "crates/multi-agent", - "crates/delegate-tool", - "crates/injection-defense", - "crates/plugin-runtime", - "crates/fused-runtime", - "crates/slack-adapter", - "crates/channel-matrix", - "crates/channel-discord", - "crates/channel-telegram", - "crates/server", - "crates/admin-ui", - "crates/e2e-tests", - "crates/eval-harness", - "crates/memory-eval", - "crates/browser", - "crates/terminal", - "crates/web", - "crates/webhook", - "crates/config", - "crates/health", - "crates/logs", - "crates/resilience", - "crates/durability", - "crates/benches", -] +members = [ "crates/acp", "crates/approvals", "crates/core-types", "crates/cap-token", "crates/embeddings", "crates/bm25-index", "crates/fusion", "crates/receipt", "crates/lifecycle-hooks", "crates/hooks-openclaw-compat", "crates/cedar-policy", "crates/memory", "crates/memory-qdrant", "crates/runtime", "crates/automation", "crates/cron", "crates/cron-ui", "crates/standing-goals", "crates/cost-gate", "crates/provider-runtime", "crates/media-decode", "crates/media-audio", "crates/media-video", "crates/provider-openrouter", "crates/provider-openai-compat", "crates/provider-ollama", "crates/provider-codex", "crates/provider-claude-cli", "crates/provider-selector", "crates/cli", "crates/messaging-gateway", "crates/tool-registry", "crates/session-journals", "crates/multi-agent", "crates/delegate-tool", "crates/injection-defense", "crates/plugin-runtime", "crates/fused-runtime", "crates/slack-adapter", "crates/channel-matrix", "crates/channel-discord", "crates/channel-telegram", "crates/server", "crates/admin-ui", "crates/e2e-tests", "crates/eval-harness", "crates/memory-eval", "crates/browser", "crates/terminal", "crates/web", "crates/webhook", "crates/config", "crates/health", "crates/logs", "crates/resilience", "crates/durability", "crates/benches", "crates/code-execution",] [workspace.package] edition = "2024" -# Edition 2024 stabilized in Rust 1.85, but that is NOT a verified MSRV -# floor for this workspace: `cargo +1.85.0 check --workspace` fails outright -# against several transitive dependencies (matrix-sdk-common needs 1.93, -# ruma 1.89, tonic 1.88, tantivy 1.86, ...) — confirmed 2026-07-12 (deep code -# review finding M11). `rust-version` is left at the edition floor as a -# lower bound only; nothing in CI gates against it (ci.yml's `gauntlet` job -# installs 1.96.1). Raising it to the real floor is a larger, separate -# change: clippy's MSRV-aware lints (e.g. let-chain collapsing) start firing -# differently the moment `rust-version` moves, which touches unrelated code -# across ~20 files — out of scope here. rust-version = "1.85" license = "Apache-2.0" repository = "https://github.com/ArdurAI/ardur-agent" [workspace.dependencies] -# --- External crates (pinned at the workspace level; crates opt in with -# `{ workspace = true }` so versions stay uniform). --- -# Cap-tokens — Biscuit issuance + attenuation (crates/cap-token, §11.14). biscuit-auth = "6" -# Cedar policy engine — wrapped by crates/cedar-policy (§11.0). cedar-policy = "4.11" -# Bi-temporal time for crates/memory (§7.0). -chrono = { version = "0.4", features = ["serde"] } -# JWS-ES256 receipt signing for crates/receipt (§11.14). Built directly on -# p256 (ECDSA P-256 + SHA-256) rather than a JWT facade: the receipt crate -# needs the raw EC `x`/`y` affine coordinates to publish JWKS keys and to -# reconstruct verifying keys offline (ADR-Phase3-549). `pem` enables the -# PKCS#8 PEM round-trip for key custody; `rand_core`'s `getrandom` backs -# `Es256SigningKey::generate`. -p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] } -rand_core = { version = "0.6", features = ["getrandom"] } sha2 = "0.11" -# Slack signing-secret verification (crates/slack-adapter, §4.1): HMAC-SHA256 -# over the request basestring, hex-encoded, compared in constant time. `hmac` -# pairs with the workspace `sha2`; `subtle` is the constant-time eq; `hex` -# encodes the digest to Slack's `v0=...` wire form. hmac = "0.13" subtle = "2" hex = "0.4" -# Secret custody for the Slack bot token + signing secret (crates/slack-adapter, -# §4.1) — keeps them out of Debug/log output behind `SecretString`. secrecy = "0.10" base64 = "0.23" # Race-resistant Unix opens for private state and exported session bundles. libc = "0.2" -# Descriptor-relative directory traversal for journal paths; prevents parent -# symlink substitution between validation and open. -rustix = { version = "1.1", features = ["fs"] } regex = "1" once_cell = "1" -# HTTP client for the live provider backends (crates/provider-runtime, §3.1). -# `rustls-tls` keeps the TLS stack pure-Rust — no OpenSSL/native-tls system -# dependency; `json` pulls the serde request/response helpers; `stream` -# exposes `Response::bytes_stream()`, used by crates/terminal's ModalBackend -# to bound response-body reads instead of buffering an unbounded body before -# parsing it. -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } ignore = "0.4" cargo-lock = "11.0" -# URL parsing + host classification for the §6.2 built-in `http.fetch` tool -# (crates/tool-registry): rejects relative/non-http(s) URLs and exposes the -# host as a typed `Host` (domain vs IP literal) for the SSRF allowlist + IP -# checks. Already in the lockfile via reqwest's transitive use. url = "2" -# Serialization. -serde = { version = "1", features = ["derive"] } serde_json = "1" -# Direct protobuf decoding for signed Biscuit block introspection in cap-token. prost = "0.10" -# YAML frontmatter parsing for filesystem SKILL.md skills (crates/tool-registry, -# §8.X). serde_yaml is archived upstream but remains the de-facto serde YAML -# deserializer; the frontmatter parsed here is small, trusted, author-authored. serde_yaml = "0.9" -# Archive creation/extraction for `ardur backup` (crates/cli). tar = "0.4" flate2 = "1" -# Async runtime (crates/runtime, §1.0). -tokio = { version = "1", features = ["full"] } -# HTTP server framework backing the `ardur-server` binary (crates/server): the -# Slack Events-API webhook listener + `/healthz`. `tower` supplies the -# `ServiceExt::oneshot` the router is driven through in-process by the server's -# integration tests (no real listener needed). axum = "0.8" -tower = { version = "0.5", features = ["util"] } -# Compile-time HTML templating for the `ardur-admin` observability dashboard -# (crates/admin-ui, §13.X). maud renders HTML from a Rust macro — no template -# files, no runtime template engine, a single proc-macro dependency — and its -# `axum` feature makes `Markup` an `IntoResponse` directly. Chosen over askama -# (needs a templates dir + build step) and tera (a runtime engine, explicitly -# excluded) because it is the lightest server-rendering option for a read-only -# dashboard. -maud = { version = "0.27", features = ["axum"] } -# In-process axum test harness — `axum-test::TestServer` drives the admin-ui -# router without binding a port, backing the endpoint integration tests -# (crates/admin-ui dev-dependency, §13.X). axum-test = "21" -# Tracing & observability. tracing = "0.1" -# OpenTelemetry GenAI semantic-convention emission (crates/provider-runtime -# `telemetry`). The provider-dispatch span carries `gen_ai.*` attributes; an -# OTLP exporter ships them to any OTLP-native backend — Langfuse / Phoenix / -# Arize / Jaeger — "for free". Keep the OTel crates and tracing bridge on a -# verified-compatible set so trait types do not split across crate versions. -# default-features are stripped so we pull only the trace signal — no -# metrics/logs pipelines we don't emit. -opentelemetry = { version = "0.32", default-features = false, features = ["trace"] } -opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["rt-tokio", "trace"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["grpc-tonic", "trace"] } tracing-opentelemetry = "0.33" -# Error handling. anyhow = "1" thiserror = "2" -# Stable IDs (UUIDv7 — time-ordered) for runtime turn/session ids. -uuid = { version = "1", features = ["v7", "serde"] } -# Non-poisoning sync primitives for the in-process memory store (crates/memory, §7.0). parking_lot = "0.12" -# Qdrant gRPC client backing the durable memory store (crates/memory-qdrant, §7.0 -# Phase 2). Pinned to the 1.18 line, matching the Qdrant server release track. qdrant-client = "1.18" -# Async trait methods — object-safe `Provider` trait (crates/provider-runtime, -# §3.0) and the cost-admission gate + budget store (crates/cost-gate, §11.14). async-trait = "0.1" -# CLI argument parsing — the `ardur` binary's subcommands (crates/cli, §2.1). -clap = { version = "4", features = ["derive", "env"] } -# Tracing subscriber — the CLI installs the process-wide log formatter (§2.1). tracing-subscriber = "0.3" -# Interactive line editor backing the chat REPL (crates/cli, §2.1). rustyline = "18" -# §2.X stunning-CLI rendering core (crates/cli, ADR-Phase2-021). CommonMark -# parser — we walk comrak's AST and render to our *own* styled terminal spans -# (not its HTML/ANSI formatters), so `default-features = false` drops the CLI, -# xdg, syntect-bridge, and shortcode features we don't use and keeps just the -# parser + node arena. -comrak = { version = "0.54", default-features = false } -# Syntax highlighting for fenced code blocks. `regex-fancy` is the pure-Rust -# regex engine (avoids the `onig` C dependency in CI); `default-syntaxes` + -# `default-themes` bundle the Sublime grammars + themes we map languages and -# light/dark palettes onto. Prior art: Codex CLI ships syntect highlighting. -syntect = { version = "5.3", default-features = false, features = [ - "parsing", - "default-syntaxes", - "default-themes", - "regex-fancy", -] } -# Terminal capability probing for the rendering core — `terminal::size()` for -# width-adaptive boxes/tables. `default-features = false` drops the event reader -# and Windows bracketed-paste machinery (Phase 2's TUI opts those back in). -crossterm = { version = "0.29", default-features = false } -# Black-box CLI assertions — drives the built `ardur` binary in integration -# tests (crates/cli dev-dependency, §2.1). assert_cmd = "2" -# `assert_cmd`'s stdout/stderr predicate matchers (crates/cli dev-dependency). predicates = "3" -# Serial test execution for env-mutating integration tests — prevents races -# on process-global environment variables (crates/server, crates/provider-selector -# dev-dependency). serial_test = "3" -# Throwaway directories for the file-backed session-journal tests -# (crates/session-journals dev-dependency, §7.10). tempfile = "3" -# HTTP mock server — stands in for `chat.postMessage` so the Slack adapter's -# outbound POST is asserted without a live workspace (crates/slack-adapter + -# crates/e2e-tests dev-dependency, §4.1). wiremock = "0.6" -# Concurrent future joining — `join_all` drives the multi-agent parallel-asks -# test without spawning (the `?Send` runtime cannot move across threads) -# (crates/multi-agent dev-dependency, §5.0). futures = "0.3" -# Official Model Context Protocol Rust SDK (§6.0 Phase 2). The tool-registry -# bridges its `ToolRegistry` to MCP — `server` for the `ServerHandler` that -# exposes local tools, `client` + `transport-streamable-http-client-reqwest` -# for the `RemoteMcpToolset` that consumes remote ones, and -# `transport-streamable-http-server` for the axum-mounted Streamable-HTTP -# service in `crates/server`. Pinned here so both crates share one version. -# Held at 2.0.0 (see dependabot.yml ignore + #237): 2.2.0 calls -# `SseStream::from_bytes_stream`, which the `sse-stream` version in our lock -# (0.2.3, exposing only `from_byte_stream`) doesn't have — E0599 on every -# platform. Unpin once `sse-stream` ships the renamed method. -rmcp = { version = "=2.0.0", default-features = false, features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client-reqwest"] } -# Docker daemon API for terminal Docker exec backend (ARD-315/ARD-324). bollard = "0.21" -# Jitter for the shared resilience layer's retry/backoff (crates/resilience). -# Plain `rand`, not `rand_core` alone, because jitter needs a full RNG -# (`random_range`) rather than the raw byte-fill trait rand_core exposes. -# Pinned to 0.10 to match `crates/cli`'s existing direct `rand = "0.10"` dep -# (#265) — one version in the tree, no cargo-deny duplicate-version warning. rand = "0.10" -# SSH client library for terminal SSH remote backend (ARD-315/ARD-324). Disable -# russh's default `rsa` feature because upstream rsa has an unfixed Marvin timing -# side-channel advisory (RUSTSEC-2023-0071); keep compression and aws-lc crypto. -russh = { version = "0.62", default-features = false, features = ["flate2", "aws-lc-rs"] } - -# --- Internal crate path-deps (one per Phase-0 crate). Declared here so -# cross-crate wiring in Phase 1 references `{ workspace = true }`. --- -ardur-core-types = { path = "crates/core-types" } -ardur-cap-token = { path = "crates/cap-token" } -ardur-approvals = { path = "crates/approvals" } -ardur-embeddings = { path = "crates/embeddings" } -ardur-bm25-index = { path = "crates/bm25-index" } -ardur-fusion = { path = "crates/fusion" } -ardur-receipt = { path = "crates/receipt" } -ardur-lifecycle-hooks = { path = "crates/lifecycle-hooks" } -ardur-hooks-openclaw-compat = { path = "crates/hooks-openclaw-compat" } -ardur-cedar-policy = { path = "crates/cedar-policy" } -ardur-memory = { path = "crates/memory" } -ardur-memory-qdrant = { path = "crates/memory-qdrant" } -ardur-memory-eval = { path = "crates/memory-eval" } -ardur-runtime = { path = "crates/runtime" } -ardur-acp = { path = "crates/acp" } -ardur-automation = { path = "crates/automation" } -ardur-cron = { path = "crates/cron" } -ardur-cron-ui = { path = "crates/cron-ui" } -ardur-standing-goals = { path = "crates/standing-goals" } -ardur-cost-gate = { path = "crates/cost-gate" } -ardur-provider-runtime = { path = "crates/provider-runtime" } -ardur-media-decode = { path = "crates/media-decode" } -ardur-media-audio = { path = "crates/media-audio" } -ardur-media-video = { path = "crates/media-video" } -ardur-provider-openrouter = { path = "crates/provider-openrouter" } -ardur-provider-openai-compat = { path = "crates/provider-openai-compat" } -ardur-provider-ollama = { path = "crates/provider-ollama" } -ardur-provider-codex = { path = "crates/provider-codex" } -ardur-provider-claude-cli = { path = "crates/provider-claude-cli" } -ardur-provider-selector = { path = "crates/provider-selector" } -ardur-messaging-gateway = { path = "crates/messaging-gateway" } -ardur-tool-registry = { path = "crates/tool-registry" } -ardur-session-journals = { path = "crates/session-journals" } -ardur-multi-agent = { path = "crates/multi-agent" } -ardur-delegate-tool = { path = "crates/delegate-tool" } -ardur-injection-defense = { path = "crates/injection-defense" } -ardur-plugin-runtime = { path = "crates/plugin-runtime" } -ardur-fused-runtime = { path = "crates/fused-runtime" } -ardur-slack-adapter = { path = "crates/slack-adapter" } -ardur-channel-matrix = { path = "crates/channel-matrix" } -ardur-channel-discord = { path = "crates/channel-discord" } -ardur-channel-telegram = { path = "crates/channel-telegram" } -ardur-server = { path = "crates/server" } -ardur-browser = { path = "crates/browser" } -ardur-terminal = { path = "crates/terminal" } -ardur-web = { path = "crates/web" } -ardur-config = { path = "crates/config" } -ardur-health = { path = "crates/health" } -ardur-logs = { path = "crates/logs" } -ardur-resilience = { path = "crates/resilience" } -ardur-durability = { path = "crates/durability" } -ardur-webhook = { path = "crates/webhook" } + +[workspace.dependencies.chrono] +version = "0.4" +features = [ "serde",] + +[workspace.dependencies.p256] +version = "0.13" +features = [ "ecdsa", "pkcs8", "pem",] + +[workspace.dependencies.rand_core] +version = "0.6" +features = [ "getrandom",] + +[workspace.dependencies.rustix] +version = "1.1" +features = [ "fs",] + +[workspace.dependencies.reqwest] +version = "0.12" +default-features = false +features = [ "json", "multipart", "rustls-tls", "stream",] + +[workspace.dependencies.serde] +version = "1" +features = [ "derive",] + +[workspace.dependencies.tokio] +version = "1" +features = [ "full",] + +[workspace.dependencies.tower] +version = "0.5" +features = [ "util",] + +[workspace.dependencies.maud] +version = "0.27" +features = [ "axum",] + +[workspace.dependencies.opentelemetry] +version = "0.32" +default-features = false +features = [ "trace",] + +[workspace.dependencies.opentelemetry_sdk] +version = "0.32.1" +default-features = false +features = [ "rt-tokio", "trace",] + +[workspace.dependencies.opentelemetry-otlp] +version = "0.32" +default-features = false +features = [ "grpc-tonic", "trace",] + +[workspace.dependencies.uuid] +version = "1" +features = [ "v7", "serde",] + +[workspace.dependencies.clap] +version = "4" +features = [ "derive", "env",] + +[workspace.dependencies.comrak] +version = "0.54" +default-features = false + +[workspace.dependencies.syntect] +version = "5.3" +default-features = false +features = [ "parsing", "default-syntaxes", "default-themes", "regex-fancy",] + +[workspace.dependencies.crossterm] +version = "0.29" +default-features = false + +[workspace.dependencies.rmcp] +version = "=2.0.0" +default-features = false +features = [ "server", "client", "transport-streamable-http-server", "transport-streamable-http-client-reqwest",] + +[workspace.dependencies.russh] +version = "0.62" +default-features = false +features = [ "flate2", "aws-lc-rs",] + +[workspace.dependencies.ardur-core-types] +path = "crates/core-types" + +[workspace.dependencies.ardur-cap-token] +path = "crates/cap-token" + +[workspace.dependencies.ardur-approvals] +path = "crates/approvals" + +[workspace.dependencies.ardur-embeddings] +path = "crates/embeddings" + +[workspace.dependencies.ardur-bm25-index] +path = "crates/bm25-index" + +[workspace.dependencies.ardur-fusion] +path = "crates/fusion" + +[workspace.dependencies.ardur-receipt] +path = "crates/receipt" + +[workspace.dependencies.ardur-lifecycle-hooks] +path = "crates/lifecycle-hooks" + +[workspace.dependencies.ardur-hooks-openclaw-compat] +path = "crates/hooks-openclaw-compat" + +[workspace.dependencies.ardur-cedar-policy] +path = "crates/cedar-policy" + +[workspace.dependencies.ardur-memory] +path = "crates/memory" + +[workspace.dependencies.ardur-memory-qdrant] +path = "crates/memory-qdrant" + +[workspace.dependencies.ardur-memory-eval] +path = "crates/memory-eval" + +[workspace.dependencies.ardur-runtime] +path = "crates/runtime" + +[workspace.dependencies.ardur-acp] +path = "crates/acp" + +[workspace.dependencies.ardur-automation] +path = "crates/automation" + +[workspace.dependencies.ardur-cron] +path = "crates/cron" + +[workspace.dependencies.ardur-cron-ui] +path = "crates/cron-ui" + +[workspace.dependencies.ardur-standing-goals] +path = "crates/standing-goals" + +[workspace.dependencies.ardur-cost-gate] +path = "crates/cost-gate" + +[workspace.dependencies.ardur-provider-runtime] +path = "crates/provider-runtime" + +[workspace.dependencies.ardur-media-decode] +path = "crates/media-decode" + +[workspace.dependencies.ardur-media-audio] +path = "crates/media-audio" + +[workspace.dependencies.ardur-media-video] +path = "crates/media-video" + +[workspace.dependencies.ardur-provider-openrouter] +path = "crates/provider-openrouter" + +[workspace.dependencies.ardur-provider-openai-compat] +path = "crates/provider-openai-compat" + +[workspace.dependencies.ardur-provider-ollama] +path = "crates/provider-ollama" + +[workspace.dependencies.ardur-provider-codex] +path = "crates/provider-codex" + +[workspace.dependencies.ardur-provider-claude-cli] +path = "crates/provider-claude-cli" + +[workspace.dependencies.ardur-provider-selector] +path = "crates/provider-selector" + +[workspace.dependencies.ardur-messaging-gateway] +path = "crates/messaging-gateway" + +[workspace.dependencies.ardur-tool-registry] +path = "crates/tool-registry" + +[workspace.dependencies.ardur-session-journals] +path = "crates/session-journals" + +[workspace.dependencies.ardur-multi-agent] +path = "crates/multi-agent" + +[workspace.dependencies.ardur-delegate-tool] +path = "crates/delegate-tool" + +[workspace.dependencies.ardur-injection-defense] +path = "crates/injection-defense" + +[workspace.dependencies.ardur-plugin-runtime] +path = "crates/plugin-runtime" + +[workspace.dependencies.ardur-fused-runtime] +path = "crates/fused-runtime" + +[workspace.dependencies.ardur-slack-adapter] +path = "crates/slack-adapter" + +[workspace.dependencies.ardur-channel-matrix] +path = "crates/channel-matrix" + +[workspace.dependencies.ardur-channel-discord] +path = "crates/channel-discord" + +[workspace.dependencies.ardur-channel-telegram] +path = "crates/channel-telegram" + +[workspace.dependencies.ardur-server] +path = "crates/server" + +[workspace.dependencies.ardur-browser] +path = "crates/browser" + +[workspace.dependencies.ardur-terminal] +path = "crates/terminal" + +[workspace.dependencies.ardur-web] +path = "crates/web" + +[workspace.dependencies.ardur-config] +path = "crates/config" + +[workspace.dependencies.ardur-health] +path = "crates/health" + +[workspace.dependencies.ardur-logs] +path = "crates/logs" + +[workspace.dependencies.ardur-resilience] +path = "crates/resilience" + +[workspace.dependencies.ardur-durability] +path = "crates/durability" + +[workspace.dependencies.ardur-webhook] +path = "crates/webhook" + +[workspace.dependencies.ardur-code-execution] +path = "crates/code-execution" diff --git a/crates/code-execution/Cargo.toml b/crates/code-execution/Cargo.toml new file mode 100644 index 0000000..0212e36 --- /dev/null +++ b/crates/code-execution/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ardur-code-execution" +version = "0.0.1" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# §6.0 tool registry — the Tool trait, Capability, and shared types. +ardur-tool-registry = { workspace = true } +# §1.0 runtime — shared value types (CapTokenRef, CostTuple, SessionId). +ardur-runtime = { workspace = true } +# §11.16 prompt-injection defense — scan captured stdout before it reaches +# the model. +ardur-injection-defense = { workspace = true } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/code-execution/src/adapter.rs b/crates/code-execution/src/adapter.rs new file mode 100644 index 0000000..145ddfc --- /dev/null +++ b/crates/code-execution/src/adapter.rs @@ -0,0 +1,239 @@ +//! The closed [`LanguageAdapter`] trait and its concrete implementations. +//! +//! Each adapter knows how to launch one language's interpreter/compiler +//! against a caller-supplied source body and hand back captured stdout, +//! stderr, and an exit code within a wall-clock ceiling. Adapters do not see +//! the cap-token caveat directly — [`crate::CodeExecutionTool`] attenuates the +//! request before an adapter ever runs. +//! +//! # Phase 1 +//! +//! [`BashLanguageAdapter`] and [`PythonLanguageAdapter`] run the child +//! process directly on the local host — the §6.3 backend matrix (Docker / +//! SSH / Singularity / Modal / Daytona / Vercel) and the §11.5 sandbox +//! runtime this crate must eventually route every dispatch through do not +//! exist yet in this workspace. Until they land, callers MUST treat this +//! crate's execution as **unsandboxed local process execution** — the +//! cap-token caveat's `tool_allowlist` and language/timeout ceilings are +//! enforced, but process isolation is whatever the host OS gives a bare +//! child process. See [`crate::CodeExecutionCaveat`] for the enforced +//! ceilings. +//! +//! Tool-call RPC (the child script calling back into the tool registry over +//! a UDS/file transport) is also Phase 2 — `tool_allowlist` is accepted, +//! attenuated, and receipted, but no stub module is generated yet and a +//! script cannot actually dispatch a tool call in Phase 1. + +use std::process::Stdio; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::error::CodeExecutionError; + +/// Output captured from one adapter run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdapterOutput { + /// The child process's captured stdout. + pub stdout: String, + /// The child process's captured stderr. + pub stderr: String, + /// The child process's exit code, or `-1` if it was killed on timeout. + pub exit_code: i32, + /// Wall-clock duration of the run, in milliseconds. + pub duration_ms: u64, +} + +/// A language a [`crate::CodeExecutionTool`] can dispatch a script to. +/// +/// Sealed via a private supertrait so only this crate can add adapters — a +/// new language is a reviewed change to §6.7, not a third-party extension +/// point. See the differentiation note in +/// `plans/6.7-code-execution-tool-call-rpc-blueprint.md`. +#[async_trait] +pub trait LanguageAdapter: private::Sealed + Send + Sync { + /// The adapter's stable name, matching the request's `language` field + /// (e.g. `"bash"`, `"python"`). + fn name(&self) -> &'static str; + + /// Run `code` with `stdin` piped in, killing the child if it exceeds + /// `timeout`. + async fn run( + &self, + code: &str, + stdin: Option<&str>, + timeout: Duration, + ) -> Result; +} + +mod private { + pub trait Sealed {} + impl Sealed for super::BashLanguageAdapter {} + impl Sealed for super::PythonLanguageAdapter {} +} + +/// Run `program` with `args`, feeding it `code` as its script body via a +/// temporary argument/stdin split appropriate to `program`, and capture its +/// output within `timeout`. +async fn run_captured( + language: &'static str, + mut cmd: Command, + stdin: Option<&str>, + timeout: Duration, +) -> Result { + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let started = std::time::Instant::now(); + let mut child = cmd.spawn().map_err(|source| CodeExecutionError::Spawn { + language: language.to_string(), + source, + })?; + + if let Some(input) = stdin { + if let Some(mut pipe) = child.stdin.take() { + let _ = pipe.write_all(input.as_bytes()).await; + } + } else { + // Drop stdin so the child sees EOF immediately rather than blocking. + drop(child.stdin.take()); + } + + let output = tokio::time::timeout(timeout, child.wait_with_output()).await; + let duration_ms = started.elapsed().as_millis() as u64; + + match output { + Ok(Ok(out)) => Ok(AdapterOutput { + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + exit_code: out.status.code().unwrap_or(-1), + duration_ms, + }), + Ok(Err(source)) => Err(CodeExecutionError::Spawn { + language: language.to_string(), + source, + }), + Err(_) => Err(CodeExecutionError::Timeout(timeout.as_secs())), + } +} + +/// `LanguageAdapter::name() == "bash"` — direct `bash -c ` invocation. +/// +/// No language environment bootstrap is required; this is the lightest- +/// weight adapter and the one most suited to "run this command and pipe its +/// output through this tool" workflows once tool-call RPC lands. +#[derive(Clone, Copy, Debug, Default)] +pub struct BashLanguageAdapter; + +#[async_trait] +impl LanguageAdapter for BashLanguageAdapter { + fn name(&self) -> &'static str { + "bash" + } + + async fn run( + &self, + code: &str, + stdin: Option<&str>, + timeout: Duration, + ) -> Result { + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(code); + run_captured("bash", cmd, stdin, timeout).await + } +} + +/// `LanguageAdapter::name() == "python"` — direct `python3 -c ` +/// invocation. +/// +/// Phase 1 runs against whatever `python3` is on `PATH`; the per-mission +/// `uv`-managed virtualenv and `ardur-tools` stub bootstrap described in the +/// §6.7 blueprint are Phase 2 (they depend on the tool-call RPC transport +/// this crate has not wired yet). +#[derive(Clone, Copy, Debug, Default)] +pub struct PythonLanguageAdapter; + +#[async_trait] +impl LanguageAdapter for PythonLanguageAdapter { + fn name(&self) -> &'static str { + "python" + } + + async fn run( + &self, + code: &str, + stdin: Option<&str>, + timeout: Duration, + ) -> Result { + let mut cmd = Command::new("python3"); + cmd.arg("-c").arg(code); + run_captured("python", cmd, stdin, timeout).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn bash_adapter_captures_stdout() { + let adapter = BashLanguageAdapter; + let out = adapter + .run("echo hello", None, Duration::from_secs(5)) + .await + .expect("bash run succeeds"); + assert_eq!(out.stdout.trim(), "hello"); + assert_eq!(out.exit_code, 0); + } + + #[tokio::test] + async fn bash_adapter_pipes_stdin() { + let adapter = BashLanguageAdapter; + let out = adapter + .run("cat", Some("piped\n"), Duration::from_secs(5)) + .await + .expect("bash run succeeds"); + assert_eq!(out.stdout, "piped\n"); + } + + #[tokio::test] + async fn bash_adapter_captures_nonzero_exit() { + let adapter = BashLanguageAdapter; + let out = adapter + .run("exit 7", None, Duration::from_secs(5)) + .await + .expect("bash run succeeds"); + assert_eq!(out.exit_code, 7); + } + + #[tokio::test] + async fn bash_adapter_times_out() { + let adapter = BashLanguageAdapter; + let result = adapter + .run("sleep 5", None, Duration::from_millis(50)) + .await; + assert!(matches!(result, Err(CodeExecutionError::Timeout(_)))); + } + + #[tokio::test] + async fn python_adapter_captures_stdout() { + let adapter = PythonLanguageAdapter; + let result = adapter + .run("print('hi')", None, Duration::from_secs(5)) + .await; + // python3 may not be present on every CI runner; only assert the + // shape of a successful run when it is. + if let Ok(out) = result { + assert_eq!(out.stdout.trim(), "hi"); + } + } + + #[test] + fn adapter_names_are_stable() { + assert_eq!(BashLanguageAdapter.name(), "bash"); + assert_eq!(PythonLanguageAdapter.name(), "python"); + } +} diff --git a/crates/code-execution/src/caveat.rs b/crates/code-execution/src/caveat.rs new file mode 100644 index 0000000..f2ded94 --- /dev/null +++ b/crates/code-execution/src/caveat.rs @@ -0,0 +1,178 @@ +//! [`CodeExecutionCaveat`] — the operator/cap-token-declared ceiling every +//! `code.exec` request is attenuated against before an adapter runs. +//! +//! Per the §6.7 blueprint's Differentiation Note 2, Hermes's script can call +//! any tool registered in its parent process. Ardur narrows this: the +//! caller's per-call `tool_allowlist` is a *stated intent*, the caveat's +//! `permitted_tools` is the *operator ceiling*, and only the intersection is +//! ever honoured. +//! +//! Phase 2 mints this caveat from a verified cap-token's Biscuit block (see +//! `ardur-cap-token`); Phase 1 constructs it directly (e.g. from `ToolContext` +//! or a caller-supplied default) since cap-token-to-caveat projection is not +//! wired into this crate yet. + +use serde::{Deserialize, Serialize}; + +use crate::error::CodeExecutionError; +use crate::tool::CodeExecutionRequest; + +/// The ceilings a `code.exec` dispatch is attenuated against. +/// +/// Every field narrows (never widens) what a bare [`CodeExecutionRequest`] +/// asks for — `attenuate` never grants more than the request declared. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CodeExecutionCaveat { + /// The maximum wall-clock ceiling any single dispatch may run for, + /// regardless of what the request's `timeout_secs` asks for. + pub max_timeout_secs: u64, + /// The languages this caveat permits. A request naming a language + /// outside this set is rejected before any adapter spawns. + pub permitted_languages: Vec, + /// The tools this caveat permits the script to declare an intent to call + /// back into. The request's `tool_allowlist` is intersected against this + /// set; anything outside it is silently dropped and receipted as denied. + pub permitted_tools: Vec, + /// When `true`, forces `expose_stderr = false` on every dispatch this + /// caveat governs, regardless of what the request asks for — the + /// operator-enforced override described in Differentiation Note 6. + pub force_stderr_hidden: bool, + /// The maximum captured-output size, in bytes, before the tool truncates + /// stdout/stderr. + pub max_output_bytes: usize, +} + +impl CodeExecutionCaveat { + /// A permissive Phase-1 default: five-minute ceiling, both shipped + /// adapters permitted, no tool callbacks, stderr exposure left to the + /// caller, 256 KiB output ceiling. + /// + /// Intended for local development and tests only — production callers + /// should construct a caveat from the operator's actual cap-token grant + /// once §11.0 cap-token-to-caveat projection lands. + #[must_use] + pub fn permissive_default() -> Self { + Self { + max_timeout_secs: 300, + permitted_languages: vec!["bash".to_string(), "python".to_string()], + permitted_tools: Vec::new(), + force_stderr_hidden: false, + max_output_bytes: 256 * 1024, + } + } + + /// Attenuate `request` against this caveat, returning the narrowed + /// request the adapter actually runs, or the first violated ceiling. + pub fn attenuate( + &self, + request: &CodeExecutionRequest, + ) -> Result { + if !self + .permitted_languages + .iter() + .any(|lang| lang == &request.language) + { + return Err(CodeExecutionError::LanguageNotPermitted( + request.language.clone(), + )); + } + + if request.timeout_secs > self.max_timeout_secs { + return Err(CodeExecutionError::TimeoutCeilingExceeded { + requested: request.timeout_secs, + ceiling: self.max_timeout_secs, + }); + } + + let (allowed_tools, denied_tools): (Vec, Vec) = request + .tool_allowlist + .iter() + .cloned() + .partition(|tool| self.permitted_tools.iter().any(|t| t == tool)); + + let mut narrowed = request.clone(); + narrowed.tool_allowlist = allowed_tools; + narrowed.denied_tools = denied_tools; + if self.force_stderr_hidden { + narrowed.expose_stderr = false; + } + Ok(narrowed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::CodeExecutionRequest; + + fn request(language: &str) -> CodeExecutionRequest { + CodeExecutionRequest { + language: language.to_string(), + code: "echo hi".to_string(), + stdin: None, + timeout_secs: 10, + tool_allowlist: vec!["fs.read".to_string(), "shell.run".to_string()], + denied_tools: Vec::new(), + expose_stdout: true, + expose_stderr: true, + } + } + + #[test] + fn rejects_unpermitted_language() { + let caveat = CodeExecutionCaveat { + permitted_languages: vec!["bash".to_string()], + ..CodeExecutionCaveat::permissive_default() + }; + let err = caveat.attenuate(&request("python")).unwrap_err(); + assert!(matches!(err, CodeExecutionError::LanguageNotPermitted(_))); + } + + #[test] + fn rejects_timeout_above_ceiling() { + let caveat = CodeExecutionCaveat { + max_timeout_secs: 5, + ..CodeExecutionCaveat::permissive_default() + }; + let err = caveat.attenuate(&request("bash")).unwrap_err(); + assert!(matches!( + err, + CodeExecutionError::TimeoutCeilingExceeded { .. } + )); + } + + #[test] + fn intersects_tool_allowlist_and_records_denials() { + let caveat = CodeExecutionCaveat { + permitted_tools: vec!["fs.read".to_string()], + ..CodeExecutionCaveat::permissive_default() + }; + let narrowed = caveat.attenuate(&request("bash")).expect("attenuates"); + assert_eq!(narrowed.tool_allowlist, vec!["fs.read".to_string()]); + assert_eq!(narrowed.denied_tools, vec!["shell.run".to_string()]); + } + + #[test] + fn forces_stderr_hidden_when_caveat_demands_it() { + let caveat = CodeExecutionCaveat { + force_stderr_hidden: true, + ..CodeExecutionCaveat::permissive_default() + }; + let narrowed = caveat.attenuate(&request("bash")).expect("attenuates"); + assert!(!narrowed.expose_stderr); + } + + #[test] + fn never_widens_tool_allowlist_beyond_the_request() { + let mut caveat = CodeExecutionCaveat::permissive_default(); + caveat.permitted_tools = vec![ + "fs.read".to_string(), + "shell.run".to_string(), + "http.fetch".to_string(), + ]; + let narrowed = caveat.attenuate(&request("bash")).expect("attenuates"); + // The request only declared fs.read + shell.run — the caveat + // permitting http.fetch too must not inject it into the result. + assert_eq!(narrowed.tool_allowlist.len(), 2); + } +} diff --git a/crates/code-execution/src/error.rs b/crates/code-execution/src/error.rs new file mode 100644 index 0000000..04ef2ae --- /dev/null +++ b/crates/code-execution/src/error.rs @@ -0,0 +1,49 @@ +//! The crate's typed-error surface. + +/// Every way a [`crate::CodeExecutionTool`] dispatch can fail before it +/// reaches [`ardur_tool_registry::ToolError`]. +#[derive(Debug, thiserror::Error)] +pub enum CodeExecutionError { + /// The requested `language` is not one of the closed [`LanguageAdapter`](crate::LanguageAdapter) + /// impls this crate ships. + #[error("unsupported language: {0}")] + UnsupportedLanguage(String), + + /// The requested `language` is not in the cap-token caveat's + /// `permitted_languages` set. + #[error("language `{0}` is not permitted by the caller's cap-token caveat")] + LanguageNotPermitted(String), + + /// The requested `tool_allowlist` is not a subset of the cap-token + /// caveat's permitted tools. + #[error("tool `{0}` is not in the caller's permitted tool set")] + ToolNotPermitted(String), + + /// The requested `timeout_secs` exceeds the cap-token caveat's ceiling. + #[error("requested timeout {requested}s exceeds the caveat ceiling of {ceiling}s")] + TimeoutCeilingExceeded { + /// What the caller asked for. + requested: u64, + /// The caveat's maximum. + ceiling: u64, + }, + + /// The child process could not be spawned. + #[error("failed to spawn `{language}` adapter: {source}")] + Spawn { + /// The language adapter that failed to spawn. + language: String, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// The child process did not exit within its wall-clock ceiling. + #[error("execution timed out after {0}s")] + Timeout(u64), + + /// The prompt-injection filter blocked the captured output before it + /// could be returned to the caller. + #[error("captured output blocked by injection filter: {0}")] + InjectionBlocked(String), +} diff --git a/crates/code-execution/src/lib.rs b/crates/code-execution/src/lib.rs new file mode 100644 index 0000000..abb293a --- /dev/null +++ b/crates/code-execution/src/lib.rs @@ -0,0 +1,74 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +//! ardur-code-execution — the §6.7 `code.exec` tool: a script-execution +//! surface that lets a model collapse N tool calls into one dispatch by +//! writing a script instead of emitting N separate tool-use turns. +//! +//! Plan family: §6.7 +//! (`plans/6.7-code-execution-tool-call-rpc-blueprint.md`). +//! +//! # Phase 1 (this crate) +//! +//! - [`LanguageAdapter`] — the closed trait one interpreter/runtime backs; +//! [`BashLanguageAdapter`] and [`PythonLanguageAdapter`] are the two Phase 1 +//! impls, running directly on the local host. +//! - [`CodeExecutionCaveat`] — the operator/cap-token ceiling every request +//! is attenuated against: a language allowlist, a timeout ceiling, a +//! tool-callback allowlist, an `expose_stderr` override, and an output-size +//! ceiling. [`CodeExecutionCaveat::attenuate`] narrows a bare request and +//! never widens it. +//! - [`CodeExecutionReceipt`] / [`ReceiptKind`] — the `code.exec.{requested, +//! completed,failed,tool_denied}.v1` receipt family, chained by parent id +//! into a forest rooted at each dispatch's `Requested` receipt. +//! - [`CodeExecutionTool`] (`code.exec`) — the [`Tool`](ardur_tool_registry::Tool) +//! impl. Every dispatch scans its captured stdout through +//! `ardur-injection-defense`'s pattern-based filter before returning it to +//! the caller, and requires [`Capability::ProcessSpawn`] plus the custom +//! `code_execution` capability. +//! - [`CodeExecutionRequest`] — the attenuated request shape an adapter runs. +//! +//! # What Phase 2 adds +//! +//! This crate deliberately does not yet implement the full §6.7 surface — +//! see the inline `// TODO §6.7 Phase 2:` markers below and the module docs +//! on [`adapter`] for what is scoped out and why: +//! +//! - Tool-call RPC: the child script cannot yet call back into the tool +//! registry. `tool_allowlist` is accepted, attenuated, and receipted as a +//! stated intent, but no `UdsRpcTransport`/`FileRpcTransport` or +//! per-language stub module exists yet. +//! - Node and Rust adapters (`plans/6.7-code-execution-tool-call-rpc-blueprint.md` +//! names four languages; this crate ships the two least environment- +//! dependent ones first). +//! - §6.3 backend-matrix routing (Docker/SSH/Singularity/Modal/Daytona/ +//! Vercel) and §11.5 sandbox-runtime wrapping — neither crate exists in +//! this workspace yet. Until they land, every dispatch in this crate is +//! unsandboxed local process execution; see the `adapter` module doc for +//! the operational implication. +//! - Cap-token-to-caveat projection — [`CodeExecutionCaveat::permissive_default`] +//! is a development-only stand-in for the caveat this crate should +//! eventually mint from a verified Biscuit block via `ardur-cap-token`. + +// TODO §6.7 Phase 2: `RpcTransport` trait + `UdsRpcTransport`/`FileRpcTransport` +// impls, so a running script can dispatch calls back into the tool registry +// instead of only declaring an intent that this crate denies or no-ops. +// TODO §6.7 Phase 2: `NodeLanguageAdapter` + `RustLanguageAdapter`, and the +// per-mission language-env caching (`uv`/`npm`/`cargo`) the blueprint +// describes for all four adapters. +// TODO §6.7 Phase 2: route every dispatch through the §6.3 backend selector +// and wrap it in a §11.5 sandbox runtime once those crates exist. +// TODO §6.7 Phase 2: mint `CodeExecutionCaveat` from a verified cap-token's +// Biscuit block rather than `permissive_default()`. + +mod adapter; +mod caveat; +mod error; +mod receipt; +mod tool; + +pub use adapter::{AdapterOutput, BashLanguageAdapter, LanguageAdapter, PythonLanguageAdapter}; +pub use caveat::CodeExecutionCaveat; +pub use error::CodeExecutionError; +pub use receipt::{CodeExecutionReceipt, ReceiptKind}; +pub use tool::{CodeExecutionRequest, CodeExecutionTool, code_execution_capability}; diff --git a/crates/code-execution/src/receipt.rs b/crates/code-execution/src/receipt.rs new file mode 100644 index 0000000..3cf4340 --- /dev/null +++ b/crates/code-execution/src/receipt.rs @@ -0,0 +1,152 @@ +//! Receipts for `code.exec` dispatches. +//! +//! Every dispatch emits a `requested` receipt before the adapter runs, then +//! exactly one of `completed` or `failed` after. A denied tool-callback +//! intent (see [`crate::CodeExecutionCaveat::attenuate`]) emits one +//! `tool_denied` receipt per denied tool, each parented to the dispatch's +//! `requested` receipt — the receipt set is a forest rooted at the request, +//! matching the §6.7 blueprint's "receipt chain is a forest" invariant. + +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static RECEIPT_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// The event a [`CodeExecutionReceipt`] records. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReceiptKind { + /// `code.exec.requested.v1` — emitted once, before the adapter runs. + Requested, + /// `code.exec.completed.v1` — the adapter ran to completion (any exit + /// code counts as "completed"; a nonzero exit is not itself a failure of + /// the dispatch). + Completed, + /// `code.exec.failed.v1` — the dispatch itself failed (spawn error, + /// timeout, injection block) before/without a usable exit code. + Failed, + /// `code.exec.tool_denied.v1` — a tool named in the request's + /// `tool_allowlist` was outside the cap-token caveat's permitted set. + ToolDenied, +} + +impl ReceiptKind { + /// The receipt's schema name, matching the blueprint's `code.exec.*.v1` + /// family. + #[must_use] + pub fn schema_name(self) -> &'static str { + match self { + Self::Requested => "code.exec.requested.v1", + Self::Completed => "code.exec.completed.v1", + Self::Failed => "code.exec.failed.v1", + Self::ToolDenied => "code.exec.tool_denied.v1", + } + } +} + +/// A single receipt in a `code.exec` dispatch's receipt forest. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CodeExecutionReceipt { + /// This receipt's schema-qualified kind. + pub kind: ReceiptKind, + /// The language the dispatch ran (or attempted to run). + pub language: String, + /// Free-form detail — the failure reason, the denied tool name, or the + /// truncated exit summary, depending on `kind`. + pub detail: String, + /// Unix timestamp in milliseconds. + pub timestamp_ms: u64, + /// A unique receipt id. + pub receipt_id: String, + /// The parent receipt id this receipt anchors to (the dispatch's + /// `Requested` receipt, for every non-`Requested` kind). + pub parent_receipt_id: Option, +} + +impl CodeExecutionReceipt { + /// Mint a fresh, unparented receipt (used for the `Requested` kind that + /// roots the forest). + #[must_use] + pub fn new(kind: ReceiptKind, language: impl Into, detail: impl Into) -> Self { + let now = now_ms(); + let seq = RECEIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + Self { + kind, + language: language.into(), + detail: detail.into(), + timestamp_ms: now, + receipt_id: format!("cx-{now}-{seq}"), + parent_receipt_id: None, + } + } + + /// Anchor this receipt to a parent (typically the dispatch's `Requested` + /// receipt id). + #[must_use] + pub fn with_parent(mut self, parent_id: impl Into) -> Self { + self.parent_receipt_id = Some(parent_id.into()); + self + } + + /// Render this receipt as the JSON object folded into + /// [`ardur_tool_registry::ToolOutput::receipt_data`]. + #[must_use] + pub fn to_receipt_json(&self) -> serde_json::Value { + serde_json::json!({ + "id": self.receipt_id, + "parent_id": self.parent_receipt_id, + "schema": self.kind.schema_name(), + "language": self.language, + "detail": self.detail, + "timestamp_ms": self.timestamp_ms, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_names_match_the_blueprint_family() { + assert_eq!( + ReceiptKind::Requested.schema_name(), + "code.exec.requested.v1" + ); + assert_eq!( + ReceiptKind::Completed.schema_name(), + "code.exec.completed.v1" + ); + assert_eq!(ReceiptKind::Failed.schema_name(), "code.exec.failed.v1"); + assert_eq!( + ReceiptKind::ToolDenied.schema_name(), + "code.exec.tool_denied.v1" + ); + } + + #[test] + fn child_receipts_chain_to_the_requested_parent() { + let requested = CodeExecutionReceipt::new(ReceiptKind::Requested, "bash", "dispatch"); + let completed = CodeExecutionReceipt::new(ReceiptKind::Completed, "bash", "exit=0") + .with_parent(requested.receipt_id.clone()); + assert_eq!( + completed.parent_receipt_id.as_deref(), + Some(requested.receipt_id.as_str()) + ); + assert!(requested.parent_receipt_id.is_none()); + } + + #[test] + fn receipt_ids_are_unique() { + let a = CodeExecutionReceipt::new(ReceiptKind::Requested, "bash", "a"); + let b = CodeExecutionReceipt::new(ReceiptKind::Requested, "bash", "b"); + assert_ne!(a.receipt_id, b.receipt_id); + } +} diff --git a/crates/code-execution/src/tool.rs b/crates/code-execution/src/tool.rs new file mode 100644 index 0000000..49c3ff8 --- /dev/null +++ b/crates/code-execution/src/tool.rs @@ -0,0 +1,403 @@ +//! [`CodeExecutionTool`] — the `code.exec` [`Tool`] impl that dispatches a +//! script to a [`LanguageAdapter`], attenuated by a [`CodeExecutionCaveat`]. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use ardur_injection_defense::{FilterRegistry, PatternBasedFilter, ScannableContent, Verdict}; +use ardur_runtime::CostTuple; +use ardur_tool_registry::{ + Capability, Tool, ToolContext, ToolError, ToolId, ToolOutput, ToolSchema, +}; + +use crate::adapter::LanguageAdapter; +use crate::caveat::CodeExecutionCaveat; +use crate::receipt::{CodeExecutionReceipt, ReceiptKind}; + +/// The custom [`Capability`] every `code.exec` dispatch requires, in +/// addition to [`Capability::ProcessSpawn`]. +#[must_use] +pub fn code_execution_capability() -> Capability { + Capability::Custom("code_execution".to_string()) +} + +/// A `code.exec` request before cap-token-caveat attenuation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CodeExecutionRequest { + /// The language to run — must match a registered [`LanguageAdapter::name`]. + pub language: String, + /// The script body. + pub code: String, + /// Optional stdin piped to the child process. + pub stdin: Option, + /// The caller's requested wall-clock ceiling, in seconds. Floored by the + /// caveat's `max_timeout_secs`. + pub timeout_secs: u64, + /// Tools the caller states an intent to call back into. Intersected + /// against the caveat's `permitted_tools`; Phase 1 does not yet dispatch + /// these calls (see the `adapter` module's Phase 1 note). + pub tool_allowlist: Vec, + /// Tools requested in `tool_allowlist` but dropped by attenuation. + /// Populated by [`CodeExecutionCaveat::attenuate`]; callers should leave + /// this empty on a fresh request. + #[serde(default)] + pub denied_tools: Vec, + /// Whether stdout reaches the caller. Defaults to `true` in the schema. + pub expose_stdout: bool, + /// Whether stderr reaches the caller. The caveat may force this `false` + /// regardless of the caller's request. + pub expose_stderr: bool, +} + +fn default_true() -> bool { + true +} + +impl CodeExecutionRequest { + fn from_args(args: &serde_json::Value) -> Result { + let language = args + .get("language") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidArgs("missing `language`".to_string()))? + .to_string(); + let code = args + .get("code") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidArgs("missing `code`".to_string()))? + .to_string(); + let stdin = args + .get("stdin") + .and_then(|v| v.as_str()) + .map(str::to_string); + let timeout_secs = args + .get("timeout_secs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(30); + let tool_allowlist = args + .get("tool_allowlist") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let expose_stdout = args + .get("expose_stdout") + .and_then(serde_json::Value::as_bool) + .unwrap_or_else(default_true); + let expose_stderr = args + .get("expose_stderr") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + Ok(Self { + language, + code, + stdin, + timeout_secs, + tool_allowlist, + denied_tools: Vec::new(), + expose_stdout, + expose_stderr, + }) + } +} + +/// The `code.exec` [`Tool`]. +/// +/// Holds the closed set of [`LanguageAdapter`]s it may dispatch to and the +/// [`CodeExecutionCaveat`] every request is attenuated against before an +/// adapter runs. +pub struct CodeExecutionTool { + adapters: HashMap<&'static str, Arc>, + caveat: CodeExecutionCaveat, + injection_filters: FilterRegistry, +} + +impl CodeExecutionTool { + /// Build a tool over `adapters`, attenuating every dispatch against + /// `caveat`. Registers the built-in pattern-based injection filter over + /// captured stdout before it is returned to the caller. + #[must_use] + pub fn new(adapters: Vec>, caveat: CodeExecutionCaveat) -> Self { + let mut map = HashMap::new(); + for adapter in adapters { + map.insert(adapter.name(), adapter); + } + let injection_filters = FilterRegistry::new(); + injection_filters.register(Arc::new(PatternBasedFilter::new())); + Self { + adapters: map, + caveat, + injection_filters, + } + } + + async fn scan_output(&self, tool_id: &ToolId, output: &str) -> Result<(), ToolError> { + let content = ScannableContent::ToolOutput { + tool_id: tool_id.clone(), + output: json!(output), + }; + let scanned = self + .injection_filters + .scan_all(&content) + .await + .map_err(|e| ToolError::Internal(anyhow::anyhow!(e.to_string())))?; + if let Verdict::Block { reason } = scanned.verdict { + return Err(ToolError::Denied { + reason: format!("captured output blocked by injection filter: {reason}"), + }); + } + Ok(()) + } +} + +#[async_trait] +impl Tool for CodeExecutionTool { + fn id(&self) -> ToolId { + ToolId::new("code.exec") + } + + fn schema(&self) -> &ToolSchema { + static SCHEMA: std::sync::OnceLock = std::sync::OnceLock::new(); + SCHEMA.get_or_init(|| ToolSchema { + description: "Run a script in a permitted language and capture its output. \ + Only the script's stdout (and, if permitted, stderr) reaches the caller." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "language": {"type": "string", "description": "e.g. \"bash\" or \"python\""}, + "code": {"type": "string"}, + "stdin": {"type": "string"}, + "timeout_secs": {"type": "integer", "default": 30}, + "tool_allowlist": {"type": "array", "items": {"type": "string"}, "default": []}, + "expose_stdout": {"type": "boolean", "default": true}, + "expose_stderr": {"type": "boolean", "default": false} + }, + "required": ["language", "code"] + }), + output_schema: json!({ + "type": "object", + "properties": { + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + "exit_code": {"type": "integer"}, + "duration_ms": {"type": "integer"}, + "tool_calls_made": {"type": "integer"}, + "tool_calls_denied": {"type": "integer"} + } + }), + examples: vec![], + }) + } + + fn required_capabilities(&self) -> &[Capability] { + // `code_execution_capability()` is not `'static` (it wraps an owned + // `String`), so this crate keeps a lazily-built static slice rather + // than allocating one per call. + static CAPS: std::sync::OnceLock<[Capability; 2]> = std::sync::OnceLock::new(); + CAPS.get_or_init(|| [Capability::ProcessSpawn, code_execution_capability()]) + } + + async fn invoke( + &self, + _ctx: &ToolContext, + args: serde_json::Value, + ) -> Result { + let request = CodeExecutionRequest::from_args(&args)?; + let tool_id = self.id(); + + let requested_receipt = CodeExecutionReceipt::new( + ReceiptKind::Requested, + request.language.clone(), + "dispatch requested", + ); + + let attenuated = self + .caveat + .attenuate(&request) + .map_err(|e| ToolError::Denied { + reason: e.to_string(), + })?; + + let mut tool_denied_receipts = Vec::new(); + for denied in &attenuated.denied_tools { + tool_denied_receipts.push( + CodeExecutionReceipt::new( + ReceiptKind::ToolDenied, + request.language.clone(), + denied.clone(), + ) + .with_parent(requested_receipt.receipt_id.clone()), + ); + } + + let adapter = self + .adapters + .get(attenuated.language.as_str()) + .cloned() + .ok_or_else(|| { + ToolError::InvalidArgs(format!("unsupported language: {}", attenuated.language)) + })?; + + let timeout = Duration::from_secs(attenuated.timeout_secs.max(1)); + let run = adapter + .run(&attenuated.code, attenuated.stdin.as_deref(), timeout) + .await; + + let outcome = match run { + Ok(output) => output, + Err(source) => { + let failed = CodeExecutionReceipt::new( + ReceiptKind::Failed, + attenuated.language.clone(), + source.to_string(), + ) + .with_parent(requested_receipt.receipt_id.clone()); + return Err(ToolError::ExecutionFailed(format!( + "{source} (receipt {})", + failed.receipt_id + ))); + } + }; + + if attenuated.expose_stdout && !outcome.stdout.is_empty() { + self.scan_output(&tool_id, &outcome.stdout).await?; + } + + let completed_receipt = CodeExecutionReceipt::new( + ReceiptKind::Completed, + attenuated.language.clone(), + format!("exit={}", outcome.exit_code), + ) + .with_parent(requested_receipt.receipt_id.clone()); + + let mut receipt_data = json!({ + "requested": requested_receipt.to_receipt_json(), + "completed": completed_receipt.to_receipt_json(), + "tool_denied": tool_denied_receipts + .iter() + .map(CodeExecutionReceipt::to_receipt_json) + .collect::>(), + }); + if let Some(obj) = receipt_data.as_object_mut() { + obj.insert( + "tool_calls_made".to_string(), + json!(0), // Phase 1: no tool-call RPC transport is wired yet. + ); + obj.insert( + "tool_calls_denied".to_string(), + json!(tool_denied_receipts.len()), + ); + } + + let content = json!({ + "stdout": if attenuated.expose_stdout { outcome.stdout.clone() } else { String::new() }, + "stderr": if attenuated.expose_stderr { outcome.stderr.clone() } else { String::new() }, + "exit_code": outcome.exit_code, + "duration_ms": outcome.duration_ms, + "tool_calls_made": 0, + "tool_calls_denied": tool_denied_receipts.len(), + }); + + let cost = CostTuple { + tokens_in: 0, + tokens_out: 0, + cents: 0, + wall_ms: outcome.duration_ms, + attention_score: 0, + }; + + Ok(ToolOutput { + content, + cost, + receipt_data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::BashLanguageAdapter; + use ardur_runtime::{CapTokenRef, SessionId}; + use std::path::PathBuf; + + fn tool() -> CodeExecutionTool { + CodeExecutionTool::new( + vec![Arc::new(BashLanguageAdapter)], + CodeExecutionCaveat::permissive_default(), + ) + } + + fn ctx() -> ToolContext { + ToolContext { + cap_token: CapTokenRef("test-token".to_string()), + session_id: SessionId::new(), + invocation_id: Default::default(), + cwd: PathBuf::from("."), + env: HashMap::new(), + cost_budget_cents: 1_000, + } + } + + #[tokio::test] + async fn runs_a_permitted_bash_script() { + let output = tool() + .invoke(&ctx(), json!({"language": "bash", "code": "echo hi"})) + .await + .expect("invoke succeeds"); + assert_eq!(output.content["stdout"], "hi\n"); + assert_eq!(output.content["exit_code"], 0); + } + + #[tokio::test] + async fn rejects_an_unsupported_language() { + let err = tool() + .invoke(&ctx(), json!({"language": "ruby", "code": "puts 1"})) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Denied { .. })); + } + + #[tokio::test] + async fn hides_stderr_by_default() { + let output = tool() + .invoke(&ctx(), json!({"language": "bash", "code": "echo err 1>&2"})) + .await + .expect("invoke succeeds"); + assert_eq!(output.content["stderr"], ""); + } + + #[tokio::test] + async fn missing_code_is_invalid_args() { + let err = tool() + .invoke(&ctx(), json!({"language": "bash"})) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::InvalidArgs(_))); + } + + #[tokio::test] + async fn denied_tool_allowlist_entries_are_receipted() { + let output = tool() + .invoke( + &ctx(), + json!({ + "language": "bash", + "code": "echo hi", + "tool_allowlist": ["shell.run"] + }), + ) + .await + .expect("invoke succeeds"); + assert_eq!(output.content["tool_calls_denied"], 1); + } +}