diff --git a/Cargo.toml b/Cargo.toml
index 2b39957d..73dbe6c4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -46,6 +46,10 @@ serve = ["dep:axum", "dep:tokio", "dep:tokio-stream", "dep:tower-http"]
smt = ["dep:z3", "z3/bundled"]
smt-system-z3 = ["dep:z3"]
docgen = []
+# Dynamic verification layer: builds harnesses from findings, runs them in a
+# sandbox, reports back whether the sink fires. Off by default until the
+# static side is honest on real corpora (see ROADMAP.md).
+dynamic = []
[lib]
name = "nyx_scanner"
diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs
new file mode 100644
index 00000000..50d02205
--- /dev/null
+++ b/src/dynamic/corpus.rs
@@ -0,0 +1,89 @@
+//! Per-capability payload corpus.
+//!
+//! Each [`Cap`] maps to a small set of canonical payloads plus a matching
+//! detection oracle. Payloads are static data — adding a new one is a code
+//! review, not a runtime config knob, so they cannot drift between versions.
+//!
+//! The corpus is intentionally minimal at the start. Goal is one payload
+//! per cap that triggers reliably on the obvious case; tuning happens once
+//! we have real targets.
+
+use crate::labels::Cap;
+
+/// A single payload + the oracle that confirms it fired.
+#[derive(Debug, Clone)]
+pub struct Payload {
+ /// Bytes injected into the [`crate::dynamic::spec::PayloadSlot`].
+ pub bytes: &'static [u8],
+ /// Human label for logs and reports (`"sqli-quote-or-1"` etc.).
+ pub label: &'static str,
+ /// How we decide the sink fired. See [`Oracle`].
+ pub oracle: Oracle,
+}
+
+/// Detection strategy. Multiple oracles run in parallel; first hit wins.
+#[derive(Debug, Clone)]
+pub enum Oracle {
+ /// Substring on stdout/stderr (e.g. `"PAYLOAD-MARKER"`, SQL error text).
+ OutputContains(&'static str),
+ /// Process exited with a crash signal (SIGSEGV, SIGABRT).
+ Crash,
+ /// Outbound network connection observed to a controlled sink host.
+ OobCallback { host: &'static str },
+ /// File written outside the sandbox sandbox root.
+ FileEscape,
+ /// Non-zero exit with specific status (e.g. shell command success).
+ ExitStatus(i32),
+}
+
+/// Pick the payload set for a given cap. Empty slice = unsupported cap.
+pub fn payloads_for(cap: Cap) -> &'static [Payload] {
+ if cap.contains(Cap::SQL_QUERY) {
+ return SQLI;
+ }
+ if cap.contains(Cap::CODE_EXEC) {
+ return CMDI;
+ }
+ if cap.contains(Cap::FILE_IO) {
+ return PATH_TRAV;
+ }
+ if cap.contains(Cap::SSRF) {
+ return SSRF_PAYLOADS;
+ }
+ if cap.contains(Cap::HTML_ESCAPE) {
+ return XSS;
+ }
+ &[]
+}
+
+const SQLI: &[Payload] = &[Payload {
+ bytes: b"' OR 1=1-- NYX",
+ label: "sqli-or-1",
+ oracle: Oracle::OutputContains("NYX"),
+}];
+
+const CMDI: &[Payload] = &[Payload {
+ bytes: b"; echo NYX_PWN",
+ label: "cmdi-echo-marker",
+ oracle: Oracle::OutputContains("NYX_PWN"),
+}];
+
+const PATH_TRAV: &[Payload] = &[Payload {
+ bytes: b"../../../../etc/passwd",
+ label: "path-traversal-passwd",
+ oracle: Oracle::OutputContains("root:"),
+}];
+
+const SSRF_PAYLOADS: &[Payload] = &[Payload {
+ bytes: b"http://nyx-oob.invalid/probe",
+ label: "ssrf-oob",
+ oracle: Oracle::OobCallback {
+ host: "nyx-oob.invalid",
+ },
+}];
+
+const XSS: &[Payload] = &[Payload {
+ bytes: b"",
+ label: "xss-script-marker",
+ oracle: Oracle::OutputContains(""),
+}];
diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs
new file mode 100644
index 00000000..011c0291
--- /dev/null
+++ b/src/dynamic/harness.rs
@@ -0,0 +1,52 @@
+//! Harness code generation.
+//!
+//! Given a [`HarnessSpec`], emit a small program that:
+//!
+//! 1. Imports/loads the target module from the project tree.
+//! 2. Reads the payload from a known channel (env var `NYX_PAYLOAD`).
+//! 3. Invokes the entry point with the payload routed to the right slot.
+//! 4. Lets the sink either fire or not — the oracle observes from outside.
+//!
+//! One generator per [`Lang`]. Each emits source plus a build command.
+//! Build artefacts are staged inside the sandbox working dir, never the
+//! user's tree.
+
+use crate::dynamic::spec::HarnessSpec;
+use crate::symbol::Lang;
+use std::path::PathBuf;
+
+/// A built harness ready to hand off to the sandbox.
+#[derive(Debug, Clone)]
+pub struct BuiltHarness {
+ /// Working directory containing the harness source + any build output.
+ pub workdir: PathBuf,
+ /// Command to invoke (e.g. `["python3", "harness.py"]` or
+ /// `["./target/release/harness"]`).
+ pub command: Vec,
+ /// Environment variables to set when running. Payload bytes go in via
+ /// `NYX_PAYLOAD` regardless of language.
+ pub env: Vec<(String, String)>,
+}
+
+/// Build a harness from a spec. Returns the artefact + run command.
+///
+/// Stub: per-language emitters will live in their own files
+/// (`harness/python.rs`, `harness/rust.rs`, etc.) and dispatch off
+/// `spec.lang`.
+pub fn build(_spec: &HarnessSpec) -> Result {
+ Err(HarnessError::Unimplemented)
+}
+
+#[derive(Debug)]
+pub enum HarnessError {
+ Unimplemented,
+ UnsupportedLang(Lang),
+ BuildFailed(String),
+ Io(std::io::Error),
+}
+
+impl From for HarnessError {
+ fn from(e: std::io::Error) -> Self {
+ HarnessError::Io(e)
+ }
+}
diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs
new file mode 100644
index 00000000..411574ce
--- /dev/null
+++ b/src/dynamic/mod.rs
@@ -0,0 +1,36 @@
+//! Dynamic verification layer (feature-gated: `dynamic`).
+//!
+//! Static analysis confirms a flow exists. Dynamic execution confirms it fires.
+//! This module turns a [`crate::commands::scan::Diag`] into a runnable harness,
+//! injects a payload from a per-cap corpus, executes inside a sandbox, and
+//! reports back whether the sink actually triggered.
+//!
+//! Pipeline:
+//!
+//! ```text
+//! Diag --> HarnessSpec --> Harness (generated source/binary)
+//! |
+//! v
+//! Sandbox::run(payload)
+//! |
+//! v
+//! VerifyResult
+//! ```
+//!
+//! All submodules are read-only consumers of the static engine's output.
+//! Nothing in this tree mutates SSA, taint, or label state.
+//!
+//! Off by default. Enable with `--features dynamic`. Heavy deps (container
+//! runtime client, fuzzer harness) live behind the same gate.
+
+pub mod corpus;
+pub mod harness;
+pub mod report;
+pub mod runner;
+pub mod sandbox;
+pub mod spec;
+pub mod verify;
+
+pub use report::{VerifyResult, VerifyStatus};
+pub use spec::HarnessSpec;
+pub use verify::{verify_finding, VerifyOptions};
diff --git a/src/dynamic/report.rs b/src/dynamic/report.rs
new file mode 100644
index 00000000..324c14ad
--- /dev/null
+++ b/src/dynamic/report.rs
@@ -0,0 +1,42 @@
+//! Verdict types returned by the dynamic layer.
+//!
+//! Kept separate from the run pipeline so the CLI / JSON output side can
+//! depend on this without pulling in sandbox or harness deps.
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum VerifyStatus {
+ /// Sink fired with at least one payload. Static finding is exploitable
+ /// against the live target.
+ Confirmed,
+ /// All payloads ran cleanly. Either the path is infeasible at runtime
+ /// or the corpus is too narrow. Treat as "static-only" not "false".
+ NotConfirmed,
+ /// Could not build, run, or observe (toolchain missing, sandbox refused,
+ /// timeout on every attempt, etc.).
+ Inconclusive,
+ /// We do not yet know how to drive this finding (missing language
+ /// support, unsupported entry kind, no payloads for cap).
+ Unsupported,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VerifyResult {
+ pub finding_id: String,
+ pub status: VerifyStatus,
+ /// Label of the payload that triggered, when [`VerifyStatus::Confirmed`].
+ pub triggered_payload: Option,
+ /// Free-form note for inconclusive/unsupported cases.
+ pub reason: Option,
+ /// Per-attempt log (payload label, exit code, timed_out flag).
+ pub attempts: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AttemptSummary {
+ pub payload_label: String,
+ pub exit_code: Option,
+ pub timed_out: bool,
+ pub triggered: bool,
+}
diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs
new file mode 100644
index 00000000..f1ee22ca
--- /dev/null
+++ b/src/dynamic/runner.rs
@@ -0,0 +1,100 @@
+//! Orchestration: spec -> harness -> sandbox -> oracle -> verdict.
+//!
+//! The runner is the only place that knows about all four submodules at
+//! once. Everything below it (corpus, harness, sandbox) is independent;
+//! everything above it ([`crate::dynamic::verify`]) just calls
+//! [`run_spec`] and turns the result into a [`crate::dynamic::report::VerifyResult`].
+
+use crate::dynamic::corpus::{payloads_for, Oracle};
+use crate::dynamic::harness::{self, BuiltHarness, HarnessError};
+use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
+use crate::dynamic::spec::HarnessSpec;
+
+#[derive(Debug)]
+pub struct RunOutcome {
+ pub spec: HarnessSpec,
+ pub attempts: Vec,
+ /// First attempt that fired the sink, if any.
+ pub triggered_by: Option,
+}
+
+#[derive(Debug)]
+pub struct Attempt {
+ pub payload_label: &'static str,
+ pub outcome: SandboxOutcome,
+ pub triggered: bool,
+}
+
+#[derive(Debug)]
+pub enum RunError {
+ NoPayloadsForCap,
+ Harness(HarnessError),
+ Sandbox(SandboxError),
+}
+
+impl From for RunError {
+ fn from(e: HarnessError) -> Self {
+ RunError::Harness(e)
+ }
+}
+
+impl From for RunError {
+ fn from(e: SandboxError) -> Self {
+ RunError::Sandbox(e)
+ }
+}
+
+/// Build harness once, run every payload from the cap-matched corpus,
+/// stop at first trigger.
+pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result {
+ let payloads = payloads_for(spec.expected_cap);
+ if payloads.is_empty() {
+ return Err(RunError::NoPayloadsForCap);
+ }
+
+ let harness: BuiltHarness = harness::build(spec)?;
+
+ let mut attempts = Vec::with_capacity(payloads.len());
+ let mut triggered_by = None;
+
+ for (i, payload) in payloads.iter().enumerate() {
+ let outcome = sandbox::run(&harness, payload, opts)?;
+ let triggered = oracle_fired(&payload.oracle, &outcome);
+ attempts.push(Attempt {
+ payload_label: payload.label,
+ outcome,
+ triggered,
+ });
+ if triggered {
+ triggered_by = Some(i);
+ break;
+ }
+ }
+
+ Ok(RunOutcome {
+ spec: spec.clone(),
+ attempts,
+ triggered_by,
+ })
+}
+
+fn oracle_fired(oracle: &Oracle, outcome: &SandboxOutcome) -> bool {
+ match oracle {
+ Oracle::OutputContains(needle) => {
+ let nb = needle.as_bytes();
+ contains_subslice(&outcome.stdout, nb) || contains_subslice(&outcome.stderr, nb)
+ }
+ Oracle::Crash => matches!(outcome.exit_code, None) && !outcome.timed_out,
+ Oracle::OobCallback { .. } => outcome.oob_callback_seen,
+ Oracle::FileEscape => false, // TODO(dynamic): wire fs watcher in sandbox layer.
+ Oracle::ExitStatus(code) => outcome.exit_code == Some(*code),
+ }
+}
+
+fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
+ if needle.is_empty() || needle.len() > hay.len() {
+ return needle.is_empty();
+ }
+ hay.windows(needle.len()).any(|w| w == needle)
+}
+
diff --git a/src/dynamic/sandbox.rs b/src/dynamic/sandbox.rs
new file mode 100644
index 00000000..87a367c7
--- /dev/null
+++ b/src/dynamic/sandbox.rs
@@ -0,0 +1,90 @@
+//! Execution sandbox.
+//!
+//! The sandbox isolates a [`crate::dynamic::harness::BuiltHarness`] from
+//! the host: no outbound network except to the oracle's OOB host, no file
+//! writes outside the workdir, hard timeout, memory cap, no host PID
+//! visibility.
+//!
+//! Two backends planned, picked at runtime:
+//!
+//! - **`docker`**: portable, default on Linux/macOS. Image is a thin debian
+//! plus the language toolchain matching `spec.lang`.
+//! - **`process`**: fallback for hosts without docker. Uses OS primitives
+//! (`unshare` on Linux, `sandbox-exec` on macOS) and runs the harness
+//! directly. Less isolation; gated behind `--unsafe-sandbox`.
+//!
+//! All public state on the sandbox is owned by the caller — there is no
+//! global runtime, no daemon, no persistent containers between runs.
+
+use crate::dynamic::corpus::Payload;
+use crate::dynamic::harness::BuiltHarness;
+use std::time::Duration;
+
+/// Result of a single sandboxed run.
+#[derive(Debug, Clone)]
+pub struct SandboxOutcome {
+ /// Process exit code; `None` on timeout or signal kill.
+ pub exit_code: Option,
+ /// Captured stdout (truncated to a bound, default 64 KiB).
+ pub stdout: Vec,
+ /// Captured stderr (same bound).
+ pub stderr: Vec,
+ /// Whether the run hit `timeout`.
+ pub timed_out: bool,
+ /// Whether the OOB host received a probe.
+ pub oob_callback_seen: bool,
+ /// Wall-clock duration of the run.
+ pub duration: Duration,
+}
+
+#[derive(Debug, Clone)]
+pub struct SandboxOptions {
+ /// Hard timeout. Default: 5s.
+ pub timeout: Duration,
+ /// Memory cap in MiB. Default: 256.
+ pub memory_mib: u64,
+ /// Backend selection. `Auto` = docker if available, else process.
+ pub backend: SandboxBackend,
+}
+
+impl Default for SandboxOptions {
+ fn default() -> Self {
+ Self {
+ timeout: Duration::from_secs(5),
+ memory_mib: 256,
+ backend: SandboxBackend::Auto,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SandboxBackend {
+ Auto,
+ Docker,
+ Process,
+}
+
+#[derive(Debug)]
+pub enum SandboxError {
+ BackendUnavailable(SandboxBackend),
+ Spawn(std::io::Error),
+ Io(std::io::Error),
+}
+
+impl From for SandboxError {
+ fn from(e: std::io::Error) -> Self {
+ SandboxError::Io(e)
+ }
+}
+
+/// Run a built harness once with a chosen payload.
+///
+/// Stub: dispatches to one of the backend submodules
+/// (`sandbox/docker.rs`, `sandbox/process.rs`) once those land.
+pub fn run(
+ _harness: &BuiltHarness,
+ _payload: &Payload,
+ _opts: &SandboxOptions,
+) -> Result {
+ Err(SandboxError::BackendUnavailable(SandboxBackend::Auto))
+}
diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs
new file mode 100644
index 00000000..e05df92d
--- /dev/null
+++ b/src/dynamic/spec.rs
@@ -0,0 +1,81 @@
+//! Harness specification: the bridge between a static finding and a runnable harness.
+//!
+//! A [`HarnessSpec`] is built from a [`crate::commands::scan::Diag`] without
+//! any further analysis. It records what the dynamic side needs to know:
+//! which entry point to drive, which parameter carries the payload, what
+//! sink (cap) we expect to hit, and which language toolchain to use.
+//!
+//! Construction is total but may return `None` when the finding lacks the
+//! evidence required to drive it dynamically (no source span, no callable
+//! entry, sink in dead code, etc.). Those findings stay static-only.
+
+use crate::commands::scan::Diag;
+use crate::labels::Cap;
+use crate::symbol::Lang;
+use serde::{Deserialize, Serialize};
+
+/// What kind of entry point the harness should call.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum EntryKind {
+ /// Free function. Build a `main` that calls it directly.
+ Function,
+ /// HTTP route. Stand up the framework, send a request.
+ HttpRoute,
+ /// CLI subcommand. Spawn the binary with crafted argv.
+ CliSubcommand,
+ /// Library API surface. Build an in-process consumer.
+ LibraryApi,
+}
+
+/// Where the payload goes when the harness fires.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum PayloadSlot {
+ /// Nth positional parameter of the entry function.
+ Param(usize),
+ /// Named HTTP query parameter.
+ QueryParam(String),
+ /// HTTP request body (raw bytes).
+ HttpBody,
+ /// Environment variable.
+ EnvVar(String),
+ /// CLI argv slot (0-based, excluding argv[0]).
+ Argv(usize),
+ /// stdin.
+ Stdin,
+}
+
+/// Self-contained recipe for building and running a single harness.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HarnessSpec {
+ /// Stable id of the source finding (`Diag::id` plus location hash).
+ pub finding_id: String,
+ /// Project-relative path to the file holding the entry point.
+ pub entry_file: String,
+ /// Function/route/subcommand name to drive.
+ pub entry_name: String,
+ /// How to invoke it.
+ pub entry_kind: EntryKind,
+ /// Source language (drives toolchain selection).
+ pub lang: Lang,
+ /// Where the payload is injected.
+ pub payload_slot: PayloadSlot,
+ /// Sink capability we expect to fire (drives oracle + corpus pick).
+ pub expected_cap: Cap,
+ /// Optional symex-derived constraint hints (prefix/suffix locks, etc.).
+ /// Populated later from `Evidence::engine_notes` when available.
+ #[serde(default)]
+ pub constraint_hints: Vec,
+}
+
+impl HarnessSpec {
+ /// Build a spec from a finding. Returns `None` when the finding cannot
+ /// be driven dynamically (missing entry, ambient sink, etc.).
+ ///
+ /// Stub: real impl will read `Diag::evidence.flow_steps` to pick the
+ /// outermost entry function and walk the source span back to a parameter.
+ pub fn from_finding(_diag: &Diag) -> Option {
+ // TODO(dynamic): map flow_steps[0] -> entry function, evidence.source_span -> PayloadSlot,
+ // evidence.sink_caps -> expected_cap.
+ None
+ }
+}
diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs
new file mode 100644
index 00000000..e3f8a72d
--- /dev/null
+++ b/src/dynamic/verify.rs
@@ -0,0 +1,86 @@
+//! Top-level entry point for the dynamic layer.
+//!
+//! The CLI subcommand and any library consumer call [`verify_finding`].
+//! It is the only function the rest of the crate needs to know about.
+
+use crate::commands::scan::Diag;
+use crate::dynamic::report::{AttemptSummary, VerifyResult, VerifyStatus};
+use crate::dynamic::runner::{run_spec, RunError};
+use crate::dynamic::sandbox::SandboxOptions;
+use crate::dynamic::spec::HarnessSpec;
+
+#[derive(Debug, Clone, Default)]
+pub struct VerifyOptions {
+ pub sandbox: SandboxOptions,
+}
+
+/// Try to dynamically confirm a static finding.
+///
+/// Never fails: every error path collapses into a [`VerifyStatus`] so the
+/// caller can treat dynamic verification as best-effort enrichment.
+pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
+ let finding_id = diag.id.clone();
+
+ let Some(spec) = HarnessSpec::from_finding(diag) else {
+ return VerifyResult {
+ finding_id,
+ status: VerifyStatus::Unsupported,
+ triggered_payload: None,
+ reason: Some("no harness spec derivable from finding".into()),
+ attempts: vec![],
+ };
+ };
+
+ match run_spec(&spec, &opts.sandbox) {
+ Ok(run) => {
+ let attempts = run
+ .attempts
+ .iter()
+ .map(|a| AttemptSummary {
+ payload_label: a.payload_label.to_string(),
+ exit_code: a.outcome.exit_code,
+ timed_out: a.outcome.timed_out,
+ triggered: a.triggered,
+ })
+ .collect();
+
+ match run.triggered_by {
+ Some(i) => VerifyResult {
+ finding_id,
+ status: VerifyStatus::Confirmed,
+ triggered_payload: Some(run.attempts[i].payload_label.to_string()),
+ reason: None,
+ attempts,
+ },
+ None => VerifyResult {
+ finding_id,
+ status: VerifyStatus::NotConfirmed,
+ triggered_payload: None,
+ reason: None,
+ attempts,
+ },
+ }
+ }
+ Err(RunError::NoPayloadsForCap) => VerifyResult {
+ finding_id,
+ status: VerifyStatus::Unsupported,
+ triggered_payload: None,
+ reason: Some("no payload corpus for sink cap".into()),
+ attempts: vec![],
+ },
+ Err(RunError::Harness(e)) => VerifyResult {
+ finding_id,
+ status: VerifyStatus::Inconclusive,
+ triggered_payload: None,
+ reason: Some(format!("harness build failed: {e:?}")),
+ attempts: vec![],
+ },
+ Err(RunError::Sandbox(e)) => VerifyResult {
+ finding_id,
+ status: VerifyStatus::Inconclusive,
+ triggered_payload: None,
+ reason: Some(format!("sandbox failed: {e:?}")),
+ attempts: vec![],
+ },
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 93815af7..f6d802b5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -99,6 +99,8 @@ pub mod commands;
pub mod constraint;
pub mod convergence_telemetry;
pub mod database;
+#[cfg(feature = "dynamic")]
+pub mod dynamic;
pub mod engine_notes;
pub mod errors;
pub mod evidence;