diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f94d915..c2fdf9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: run: cargo fmt --all -- --check - name: Check example generated artifacts - run: cd examples/notes && cargo run -p hydra-codegen --bin hydra-codegen -- check + run: | + cd examples/notes && cargo run -p hydra-codegen --bin hydra-codegen -- check + cd ../security-scan && cargo run -p hydra-codegen --bin hydra-codegen -- check creed: name: Creed context drift diff --git a/Cargo.lock b/Cargo.lock index 884d21b..8dc01a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-scan-example" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "hydra-mcp-stdio", + "serde", + "serde_json", + "tokio", + "tower", +] + [[package]] name = "serde" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index a976df4..e931453 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/hydra-mcp-stdio", "crates/hydra-codegen", "examples/notes", + "examples/security-scan", ] [workspace.package] diff --git a/README.md b/README.md index 51c2def..f15c49a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,23 @@ validation and persistence. Hydra does not own source-specific event models, batch hashing, idempotency, or transactions. `examples/notes` contains a tested `ingest_batch` reference operation and is the pattern Iris should use. +### Security-scanner consumer boundary + +`examples/security-scan` is a deliberately small, fixture-backed consumer +reference. Its explicit `run_security_scan` operation projects to CLI, HTTP, +and MCP, while the consumer—not Hydra—owns a typed `SecurityScanner` trait and +the single dispatch function. The checked-in fixture only accepts +`fixture:demo-repo` and the optional `baseline` profile. It never treats input +as a command, filesystem path, or URL. + +The fixture needs no configuration. A future live adapter must read only +`DEEPSEC_ENDPOINT` (an absolute HTTPS URL) and `DEEPSEC_TOKEN` (a non-empty +credential) from its environment. Neither belongs in source, generated output, +logs, requests, or public errors. Consumer adapters must map private failures +to the fixed public `invalid_request`, `scanner_unavailable`, or `scan_failed` +error codes without serializing vendor details, credentials, headers, endpoints, +or raw scanner output. + ## Raw-request (webhook) operations Operations that must see the exact wire representation — signature-verified diff --git a/examples/security-scan/Cargo.toml b/examples/security-scan/Cargo.toml new file mode 100644 index 0000000..f776e42 --- /dev/null +++ b/examples/security-scan/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "security-scan-example" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Fixture-backed security scanner adapter boundary projected by Hydra" +publish = false + +[lints] +workspace = true + +[dependencies] +hydra-mcp-stdio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +clap = { workspace = true } +axum = { workspace = true } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/examples/security-scan/api/operations.yaml b/examples/security-scan/api/operations.yaml new file mode 100644 index 0000000..ea90e10 --- /dev/null +++ b/examples/security-scan/api/operations.yaml @@ -0,0 +1,19 @@ +operations: + - name: run_security_scan + description: Run the configured security scanner against an allowed target. + method: POST + path: /security/scans + read: false + output_type: SecurityScanResult + surfaces: [cli, http, mcp] + parameters: + - name: target + description: Closed target identifier accepted by this tracer bullet. + type: string + required: true + location: body + - name: profile + description: Explicit scanner profile; omission selects baseline. + type: string + required: false + location: body diff --git a/examples/security-scan/generated/cli.rs b/examples/security-scan/generated/cli.rs new file mode 100644 index 0000000..32bfe0d --- /dev/null +++ b/examples/security-scan/generated/cli.rs @@ -0,0 +1,36 @@ +// Code generated by hydra. DO NOT EDIT. +// CLI command structs generated from the API definition + +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Subcommand)] +pub enum GeneratedCommand { + /// Run the configured security scanner against an allowed target. + RunSecurityScan(RunSecurityScanArgs), +} + +impl GeneratedCommand { + pub const fn operation_name(&self) -> &'static str { + match self { + Self::RunSecurityScan(_) => "run_security_scan", + } + } + + pub fn parameters_json(&self) -> serde_json::Value { + match self { + Self::RunSecurityScan(args) => serde_json::json!({"target": args.target.clone(), "profile": args.profile.clone()}), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Args)] +pub struct RunSecurityScanArgs { + /// Closed target identifier accepted by this tracer bullet. + #[arg(long)] + pub target: String, + /// Explicit scanner profile; omission selects baseline. + #[arg(long)] + pub profile: Option, +} + diff --git a/examples/security-scan/generated/http.rs b/examples/security-scan/generated/http.rs new file mode 100644 index 0000000..c69aa29 --- /dev/null +++ b/examples/security-scan/generated/http.rs @@ -0,0 +1,47 @@ +// Code generated by hydra. DO NOT EDIT. +// HTTP route handlers generated from the API definition + +use std::collections::BTreeMap; + +use axum::{extract::{State}, response::Response, routing::{post}, Router}; +use axum::Json; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GeneratedRoute { + pub name: &'static str, + pub method: &'static str, + pub path: &'static str, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GeneratedOperationInput { + pub path: BTreeMap, + pub query: BTreeMap, + pub body: Value, +} + +pub const GENERATED_ROUTES: &[GeneratedRoute] = &[ + GeneratedRoute { name: "run_security_scan", method: "POST", path: "/security/scans" }, +]; + +pub fn generated_router() -> Router { + Router::new() + .route("/security/scans", post(run_security_scan)) +} + +async fn run_security_scan( + State(state): State, + Json(body): Json, +) -> Response { + crate::execute_operation_http( + &state, + "run_security_scan", + GeneratedOperationInput { + path: BTreeMap::new(), + query: BTreeMap::new(), + body, + }, + ) + .await +} diff --git a/examples/security-scan/generated/mcp.json b/examples/security-scan/generated/mcp.json new file mode 100644 index 0000000..6595c7c --- /dev/null +++ b/examples/security-scan/generated/mcp.json @@ -0,0 +1,31 @@ +{ + "locations": { + "run_security_scan": { + "profile": "body", + "target": "body" + } + }, + "tools": [ + { + "description": "Run the configured security scanner against an allowed target.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "profile": { + "description": "Explicit scanner profile; omission selects baseline.", + "type": "string" + }, + "target": { + "description": "Closed target identifier accepted by this tracer bullet.", + "type": "string" + } + }, + "required": [ + "target" + ], + "type": "object" + }, + "name": "run_security_scan" + } + ] +} diff --git a/examples/security-scan/hydra.yaml b/examples/security-scan/hydra.yaml new file mode 100644 index 0000000..0c43ed4 --- /dev/null +++ b/examples/security-scan/hydra.yaml @@ -0,0 +1,3 @@ +# Generated surfaces call the consumer-owned, typed dispatch boundary. +http_dispatch_fn: "crate::execute_operation_http" +http_state_type: "crate::AppState" diff --git a/examples/security-scan/src/bin/security-scan-cli.rs b/examples/security-scan/src/bin/security-scan-cli.rs new file mode 100644 index 0000000..1fc898c --- /dev/null +++ b/examples/security-scan/src/bin/security-scan-cli.rs @@ -0,0 +1,5 @@ +//! Security-scan example CLI binary. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + security_scan_example::run_cli().await +} diff --git a/examples/security-scan/src/bin/security-scan-http.rs b/examples/security-scan/src/bin/security-scan-http.rs new file mode 100644 index 0000000..50eb660 --- /dev/null +++ b/examples/security-scan/src/bin/security-scan-http.rs @@ -0,0 +1,5 @@ +//! Security-scan example HTTP server binary. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + security_scan_example::run_http().await +} diff --git a/examples/security-scan/src/bin/security-scan-mcp.rs b/examples/security-scan/src/bin/security-scan-mcp.rs new file mode 100644 index 0000000..cb233cd --- /dev/null +++ b/examples/security-scan/src/bin/security-scan-mcp.rs @@ -0,0 +1,5 @@ +//! Security-scan example MCP stdio binary. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + security_scan_example::run_mcp().await +} diff --git a/examples/security-scan/src/lib.rs b/examples/security-scan/src/lib.rs new file mode 100644 index 0000000..662e989 --- /dev/null +++ b/examples/security-scan/src/lib.rs @@ -0,0 +1,359 @@ +//! Fixture-backed security-scan consumer. +//! +//! Hydra projects the declared operation onto CLI, HTTP, and MCP. This crate +//! owns validation, scanner selection, and the public redaction boundary. + +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use axum::{Router, http::StatusCode, response::IntoResponse}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub mod generated { + include!("../generated/http.rs"); +} +pub mod generated_cli { + include!("../generated/cli.rs"); +} +pub const GENERATED_MCP_JSON: &str = include_str!("../generated/mcp.json"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanRequest { + target: String, + profile: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FindingSummary { + pub severity: String, + pub rule_id: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SecurityScanResult { + pub status: String, + pub target: String, + pub profile: String, + pub summary: SeveritySummary, + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SeveritySummary { + pub critical: u32, + pub high: u32, + pub medium: u32, + pub low: u32, +} + +pub trait SecurityScanner: Send + Sync { + fn scan(&self, request: &ScanRequest) -> Result; +} + +#[derive(Debug)] +pub struct ScannerError { + _private: (), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ScanArgs { + target: String, + #[serde(default)] + profile: Option, +} + +/// Deterministic checked-in adapter. It never interprets the target as a path, +/// URL, or command and requires neither network access nor configuration. +pub struct FixtureScanner; +impl SecurityScanner for FixtureScanner { + fn scan(&self, request: &ScanRequest) -> Result { + if request.target != "fixture:demo-repo" || request.profile != "baseline" { + return Err(ScannerError { _private: () }); + } + Ok(SecurityScanResult { + status: "completed".into(), + target: request.target.clone(), + profile: request.profile.clone(), + summary: SeveritySummary { + critical: 0, + high: 0, + medium: 0, + low: 0, + }, + findings: Vec::new(), + }) + } +} + +#[derive(Clone)] +pub struct AppState { + scanner: Arc, + invocations: Arc, +} +impl AppState { + #[must_use] + pub fn fixture() -> Self { + Self { + scanner: Arc::new(FixtureScanner), + invocations: Arc::new(AtomicUsize::new(0)), + } + } + #[cfg(test)] + fn invocation_count(&self) -> usize { + self.invocations.load(Ordering::SeqCst) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PublicErrorCode { + InvalidRequest, + ScannerUnavailable, + ScanFailed, +} +impl PublicErrorCode { + const fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::ScannerUnavailable => "scanner_unavailable", + Self::ScanFailed => "scan_failed", + } + } +} + +#[derive(Debug)] +pub struct OperationError { + status: StatusCode, + code: PublicErrorCode, +} +impl OperationError { + const fn invalid() -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: PublicErrorCode::InvalidRequest, + } + } + const fn unavailable() -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + code: PublicErrorCode::ScannerUnavailable, + } + } + const fn failed() -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + code: PublicErrorCode::ScanFailed, + } + } + fn public_json(&self) -> Value { + serde_json::json!({"code": self.code.as_str(), "message": "Security scan could not be completed."}) + } +} +impl std::fmt::Display for OperationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.code.as_str()) + } +} +impl std::error::Error for OperationError {} + +/// The sole generated-surface dispatch. Unknown operations are rejected +/// explicitly; adapters receive only this validated typed request. +#[allow(clippy::unused_async)] +pub async fn execute_operation( + state: &AppState, + operation: &str, + input: generated::GeneratedOperationInput, +) -> Result { + if operation != "run_security_scan" { + return Err(OperationError::invalid()); + } + let args: ScanArgs = + serde_json::from_value(input.body).map_err(|_| OperationError::invalid())?; + let request = validate_request(args.target, args.profile)?; + state.invocations.fetch_add(1, Ordering::SeqCst); + let result = state + .scanner + .scan(&request) + .map_err(|_| OperationError::failed())?; + serde_json::to_value(result).map_err(|_| OperationError::unavailable()) +} + +fn validate_request( + target: String, + profile: Option, +) -> Result { + if target != "fixture:demo-repo" || target.len() > 1024 || target.chars().any(char::is_control) + { + return Err(OperationError::invalid()); + } + let profile = profile.unwrap_or_else(|| "baseline".into()); + if profile != "baseline" { + return Err(OperationError::invalid()); + } + Ok(ScanRequest { target, profile }) +} + +pub async fn execute_operation_http( + state: &AppState, + operation: &str, + input: generated::GeneratedOperationInput, +) -> axum::response::Response { + match execute_operation(state, operation, input).await { + Ok(value) => axum::Json(value).into_response(), + Err(error) => (error.status, axum::Json(error.public_json())).into_response(), + } +} + +pub fn http_router(state: AppState) -> Router { + generated::generated_router().with_state(state) +} +pub async fn run_http() -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:8942").await?; + axum::serve(listener, http_router(AppState::fixture())).await?; + Ok(()) +} + +pub async fn run_cli() -> anyhow::Result<()> { + #[derive(Parser)] + #[command(name = "security-scan")] + struct Cli { + #[command(subcommand)] + command: generated_cli::GeneratedCommand, + } + let command = Cli::parse().command; + let params = command.parameters_json(); + let input = generated::GeneratedOperationInput { + path: BTreeMap::default(), + query: BTreeMap::default(), + body: params, + }; + match execute_operation(&AppState::fixture(), command.operation_name(), input).await { + Ok(value) => { + println!("{value}"); + Ok(()) + } + Err(error) => { + eprintln!("{}", error.public_json()); + std::process::exit(1); + } + } +} + +pub async fn run_mcp() -> anyhow::Result<()> { + let tools: Value = serde_json::from_str(GENERATED_MCP_JSON)?; + let state = AppState::fixture(); + hydra_mcp_stdio::serve( + "security-scan", + env!("CARGO_PKG_VERSION"), + tools, + move |name, args| { + let state = state.clone(); + async move { + let input = generated::GeneratedOperationInput { + path: BTreeMap::default(), + query: BTreeMap::default(), + body: args, + }; + execute_operation(&state, &name, input) + .await + .map_err(|error| error.public_json().to_string()) + } + }, + ) + .await + .map_err(|error| anyhow::anyhow!("mcp stdio error: {error}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn input(target: &str, profile: Option<&str>) -> generated::GeneratedOperationInput { + generated::GeneratedOperationInput { + path: BTreeMap::default(), + query: BTreeMap::default(), + body: serde_json::json!({"target": target, "profile": profile}), + } + } + #[tokio::test] + async fn fixture_dispatch_is_deterministic() { + let state = AppState::fixture(); + let result = execute_operation( + &state, + "run_security_scan", + input("fixture:demo-repo", None), + ) + .await + .unwrap(); + assert_eq!(result["status"], "completed"); + assert_eq!(result["profile"], "baseline"); + assert_eq!(state.invocation_count(), 1); + } + #[tokio::test] + async fn invalid_input_never_invokes_adapter() { + let state = AppState::fixture(); + assert!( + execute_operation( + &state, + "run_security_scan", + input("https://token:sentinel@example.test", None) + ) + .await + .is_err() + ); + assert_eq!(state.invocation_count(), 0); + } + #[tokio::test] + async fn undeclared_fields_never_invoke_adapter() { + let state = AppState::fixture(); + let unexpected = generated::GeneratedOperationInput { + path: BTreeMap::default(), + query: BTreeMap::default(), + body: serde_json::json!({"target": "fixture:demo-repo", "DEEPSEC_TOKEN": "sentinel-secret"}), + }; + assert!( + execute_operation(&state, "run_security_scan", unexpected) + .await + .is_err() + ); + assert_eq!(state.invocation_count(), 0); + } + #[test] + fn fixture_rejects_bypassed_invalid_typed_request() { + let request = ScanRequest { + target: "https://example.test".into(), + profile: "baseline".into(), + }; + assert!(FixtureScanner.scan(&request).is_err()); + } + #[tokio::test] + async fn public_errors_do_not_leak_secret() { + let state = AppState::fixture(); + let error = execute_operation( + &state, + "unknown_operation", + input("DEEPSEC_TOKEN=sentinel-secret", None), + ) + .await + .unwrap_err(); + let public = error.public_json().to_string(); + assert!(!public.contains("sentinel-secret")); + assert!(!public.contains("DEEPSEC_TOKEN")); + } + #[test] + fn generated_artifacts_project_all_three_surfaces() { + let cli = include_str!("../generated/cli.rs"); + let mcp: Value = serde_json::from_str(include_str!("../generated/mcp.json")).unwrap(); + assert!(cli.contains("RunSecurityScan")); + assert!(include_str!("../generated/http.rs").contains("/security/scans")); + assert_eq!(mcp["tools"][0]["name"], "run_security_scan"); + } +} diff --git a/openspec/changes/deepsec-security-scan/tasks.md b/openspec/changes/deepsec-security-scan/tasks.md index 8b06a2f..e57e0ba 100644 --- a/openspec/changes/deepsec-security-scan/tasks.md +++ b/openspec/changes/deepsec-security-scan/tasks.md @@ -2,13 +2,13 @@ ## Spec review -- [ ] 1. Review and merge this OpenSpec-only PR before implementation. +- [x] 1. Review and merge this OpenSpec-only PR before implementation. ## Implementation (blocked by spec review) -- [ ] 2. Add `examples/security-scan` as a workspace consumer with explicit `run_security_scan` operation and `hydra.yaml`. -- [ ] 3. Implement typed request/result models, the `SecurityScanner` adapter trait, and a deterministic fixture adapter behind one dispatch function. -- [ ] 4. Generate and commit CLI, HTTP, and MCP artifacts; add deterministic `hydra check` coverage. -- [ ] 5. Add dispatch and validation tests proving invalid inputs never reach the adapter and output excludes secret-bearing fields. -- [ ] 6. Document the adapter boundary, explicit environment-only DeepSec configuration, and the fact that Hydra never executes caller-provided commands. -- [ ] 7. Run the full Hydra gate: build, tests, clippy, fmt, and examples/notes codegen check; also run the security-scan codegen check. +- [x] 2. Add `examples/security-scan` as a workspace consumer with explicit `run_security_scan` operation and `hydra.yaml`. +- [x] 3. Implement typed request/result models, the `SecurityScanner` adapter trait, and a deterministic fixture adapter behind one dispatch function. +- [x] 4. Generate and commit CLI, HTTP, and MCP artifacts; add deterministic `hydra check` coverage. +- [x] 5. Add dispatch and validation tests proving invalid inputs never reach the adapter and output excludes secret-bearing fields. +- [x] 6. Document the adapter boundary, explicit environment-only DeepSec configuration, and the fact that Hydra never executes caller-provided commands. +- [x] 7. Run the full Hydra gate: build, tests, clippy, fmt, and examples/notes codegen check; also run the security-scan codegen check.