Skip to main content

nyx_agent_types/
live_plan.rs

1//! Typed live verification plans.
2//!
3//! These models are the internal contract between candidate synthesis,
4//! payload planning, and the guarded verifier. Legacy JSON plans are still
5//! accepted by the binary, but are normalized into this shape before any
6//! live request/browser action runs.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use crate::payload::{ContextualPayload, PayloadValidationError};
14
15fn default_get() -> String {
16    "GET".to_string()
17}
18
19fn default_anonymous() -> String {
20    "anonymous".to_string()
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum NoPlanReasonCode {
26    BadEndpoint,
27    AuthMissing,
28    AuthUnsupported,
29    SetupMissing,
30    WeakOracle,
31    BrowserDisabled,
32    RuntimeUnavailable,
33    MissingSeedData,
34    StateChangingBlocked,
35    TargetOutOfScope,
36    NoExecutablePlan,
37    DependencyReviewOnly,
38    UnsupportedClass,
39    UnsafeProbe,
40    RouteNotInferred,
41    Other,
42}
43
44impl NoPlanReasonCode {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::BadEndpoint => "bad_endpoint",
48            Self::AuthMissing => "auth_missing",
49            Self::AuthUnsupported => "auth_unsupported",
50            Self::SetupMissing => "setup_missing",
51            Self::WeakOracle => "weak_oracle",
52            Self::BrowserDisabled => "browser_disabled",
53            Self::RuntimeUnavailable => "runtime_unavailable",
54            Self::MissingSeedData => "missing_seed_data",
55            Self::StateChangingBlocked => "state_changing_blocked",
56            Self::TargetOutOfScope => "target_out_of_scope",
57            Self::NoExecutablePlan => "no_executable_plan",
58            Self::DependencyReviewOnly => "dependency_review_only",
59            Self::UnsupportedClass => "unsupported_class",
60            Self::UnsafeProbe => "unsafe_probe",
61            Self::RouteNotInferred => "route_not_inferred",
62            Self::Other => "other",
63        }
64    }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum EnvCapabilityStatus {
70    Available,
71    Missing,
72    Disabled,
73    Blocked,
74    Unknown,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AuthRoleCapability {
79    pub role: String,
80    pub mode: String,
81    pub status: EnvCapabilityStatus,
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub missing_env_vars: Vec<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub missing_artifacts: Vec<String>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub notes: Vec<String>,
88}
89
90impl AuthRoleCapability {
91    pub fn ready(&self) -> bool {
92        matches!(self.status, EnvCapabilityStatus::Available)
93    }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct OwnedObjectCapability {
98    pub role: String,
99    pub name: String,
100    pub id: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub route: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub marker: Option<String>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct AuthRolePairCapability {
109    pub owner_role: String,
110    pub accessor_role: String,
111    pub status: EnvCapabilityStatus,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub notes: Vec<String>,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct EnvCapabilityReport {
118    pub target_reachable: EnvCapabilityStatus,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub target_urls: Vec<String>,
121    pub browser: EnvCapabilityStatus,
122    pub seed: EnvCapabilityStatus,
123    pub reset: EnvCapabilityStatus,
124    pub mailbox: EnvCapabilityStatus,
125    pub state_changing: EnvCapabilityStatus,
126    pub exploit_mode_enabled: bool,
127    pub dry_run: bool,
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub auth_roles: Vec<AuthRoleCapability>,
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub usable_auth_role_pairs: Vec<AuthRolePairCapability>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub owned_objects: Vec<OwnedObjectCapability>,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub findings: Vec<String>,
136}
137
138impl EnvCapabilityReport {
139    pub fn auth_role(&self, role: &str) -> Option<&AuthRoleCapability> {
140        self.auth_roles.iter().find(|cap| cap.role == role)
141    }
142
143    pub fn auth_role_ready(&self, role: &str) -> bool {
144        role == "anonymous" || self.auth_role(role).is_some_and(AuthRoleCapability::ready)
145    }
146
147    pub fn missing_auth_roles<'a>(
148        &'a self,
149        roles: impl IntoIterator<Item = &'a str>,
150    ) -> Vec<&'a AuthRoleCapability> {
151        roles
152            .into_iter()
153            .filter(|role| *role != "anonymous")
154            .filter_map(|role| self.auth_role(role))
155            .filter(|cap| !cap.ready())
156            .collect()
157    }
158
159    pub fn has_owned_object_for_role(&self, role: &str) -> bool {
160        self.owned_objects.iter().any(|object| object.role == role)
161    }
162
163    pub fn ready_auth_role_pair(&self) -> Option<&AuthRolePairCapability> {
164        self.usable_auth_role_pairs
165            .iter()
166            .find(|pair| matches!(pair.status, EnvCapabilityStatus::Available))
167    }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct NoPlanReason {
172    pub code: NoPlanReasonCode,
173    pub message: String,
174    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175    pub context: BTreeMap<String, String>,
176}
177
178impl NoPlanReason {
179    pub fn new(code: NoPlanReasonCode, message: impl Into<String>) -> Self {
180        Self { code, message: message.into(), context: BTreeMap::new() }
181    }
182
183    pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
184        self.context.insert(key.into(), value.into());
185        self
186    }
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190pub struct LiveHttpRequest {
191    #[serde(default = "default_get")]
192    pub method: String,
193    pub url: String,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub path: Option<String>,
196    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
197    pub headers: BTreeMap<String, String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub body: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub json: Option<serde_json::Value>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub captures: Option<serde_json::Value>,
204    #[serde(default = "default_anonymous", rename = "as", alias = "role")]
205    pub role: String,
206    #[serde(default, skip_serializing_if = "is_false")]
207    pub destructive: bool,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub payload: Option<ContextualPayload>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub label: Option<String>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub purpose: Option<HttpWorkflowStepPurpose>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub oracle: Option<HttpOracle>,
216}
217
218impl LiveHttpRequest {
219    pub fn get(url: impl Into<String>) -> Self {
220        Self {
221            method: "GET".to_string(),
222            url: url.into(),
223            path: None,
224            headers: BTreeMap::new(),
225            body: None,
226            json: None,
227            captures: None,
228            role: default_anonymous(),
229            destructive: false,
230            payload: None,
231            label: None,
232            purpose: None,
233            oracle: None,
234        }
235    }
236}
237
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum HttpWorkflowStepPurpose {
241    Seed,
242    PositiveControl,
243    StateTransition,
244    Exploit,
245    Postcondition,
246    Cleanup,
247}
248
249#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
250pub struct HttpOracle {
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub expect_status: Vec<u16>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub status_range: Option<String>,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub body_contains: Vec<String>,
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub body_not_contains: Vec<String>,
259    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
260    pub header_contains: BTreeMap<String, String>,
261}
262
263impl HttpOracle {
264    pub fn has_positive_evidence(&self) -> bool {
265        self.body_contains.iter().any(|s| !s.trim().is_empty())
266            || self.header_contains.values().any(|s| !s.trim().is_empty())
267    }
268}
269
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
271pub struct SingleHttpPlan {
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub hypothesis: Option<String>,
274    #[serde(flatten)]
275    pub request: LiveHttpRequest,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub baseline: Option<LiveHttpRequest>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub benign: Option<LiveHttpRequest>,
280    #[serde(flatten)]
281    pub oracle: HttpOracle,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub why_this_confirms: Option<String>,
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct HttpWorkflowPlan {
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub hypothesis: Option<String>,
290    #[serde(default)]
291    pub steps: Vec<LiveHttpRequest>,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub benign_steps: Vec<LiveHttpRequest>,
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub cleanup_steps: Vec<LiveHttpRequest>,
296    pub oracle: HttpOracle,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub oracle_step: Option<usize>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub why_this_confirms: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub recipe: Option<StatefulFixtureRecipe>,
303}
304
305#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
306pub struct StatefulFixtureRecipe {
307    pub id: String,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub fixture: Option<String>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub reset_required: Option<bool>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub cleanup_required: Option<bool>,
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub required_roles: Vec<String>,
316}
317
318#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
319pub struct DifferentialOracle {
320    #[serde(default = "default_differential_oracle_type", rename = "type")]
321    pub oracle_type: String,
322    #[serde(default)]
323    pub expected_allowed_step: usize,
324    #[serde(default = "default_forbidden_step")]
325    pub expected_forbidden_step: usize,
326    #[serde(default = "default_forbidden_statuses")]
327    pub forbidden_status: Vec<u16>,
328    #[serde(default, skip_serializing_if = "Vec::is_empty")]
329    pub sensitive_body_markers: Vec<String>,
330}
331
332impl DifferentialOracle {
333    pub fn has_positive_evidence(&self) -> bool {
334        self.sensitive_body_markers.iter().any(|s| !s.trim().is_empty())
335    }
336}
337
338fn default_differential_oracle_type() -> String {
339    "forbidden_equivalence_break".to_string()
340}
341
342fn default_forbidden_step() -> usize {
343    1
344}
345
346fn default_forbidden_statuses() -> Vec<u16> {
347    vec![401, 403, 404]
348}
349
350fn default_role_comparison_oracle_type() -> String {
351    "role_comparison_break".to_string()
352}
353
354fn default_object_ownership_oracle_type() -> String {
355    "object_ownership_break".to_string()
356}
357
358#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
359pub struct AuthzOracle {
360    #[serde(default = "default_role_comparison_oracle_type", rename = "type")]
361    pub oracle_type: String,
362    #[serde(default = "default_forbidden_statuses")]
363    pub forbidden_status: Vec<u16>,
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub positive_markers: Vec<String>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub allowed_status_range: Option<String>,
368}
369
370impl AuthzOracle {
371    pub fn role_comparison(markers: Vec<String>) -> Self {
372        Self {
373            oracle_type: default_role_comparison_oracle_type(),
374            forbidden_status: default_forbidden_statuses(),
375            positive_markers: markers,
376            allowed_status_range: None,
377        }
378    }
379
380    pub fn object_ownership(markers: Vec<String>) -> Self {
381        Self {
382            oracle_type: default_object_ownership_oracle_type(),
383            forbidden_status: default_forbidden_statuses(),
384            positive_markers: markers,
385            allowed_status_range: None,
386        }
387    }
388
389    pub fn has_positive_evidence(&self) -> bool {
390        self.positive_markers.iter().any(|s| !s.trim().is_empty())
391    }
392}
393
394#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
395pub struct AuthzOwnedObject {
396    pub name: String,
397    pub owner_role: String,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub id: Option<String>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub id_var: Option<String>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub route: Option<String>,
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    pub positive_markers: Vec<String>,
406}
407
408#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
409pub struct AuthzRoleComparisonPlan {
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub hypothesis: Option<String>,
412    pub allowed_role: String,
413    pub challenged_role: String,
414    pub request: LiveHttpRequest,
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub setup_steps: Vec<LiveHttpRequest>,
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub benign_steps: Vec<LiveHttpRequest>,
419    pub oracle: AuthzOracle,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub why_this_confirms: Option<String>,
422}
423
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425pub struct AuthzObjectOwnershipPlan {
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub hypothesis: Option<String>,
428    pub object: AuthzOwnedObject,
429    pub accessor_role: String,
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub seed_steps: Vec<LiveHttpRequest>,
432    pub owner_request: LiveHttpRequest,
433    pub accessor_request: LiveHttpRequest,
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub benign_steps: Vec<LiveHttpRequest>,
436    pub oracle: AuthzOracle,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub why_this_confirms: Option<String>,
439}
440
441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
442pub struct AuthzBrowserRoleComparisonPlan {
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub hypothesis: Option<String>,
445    pub allowed_role: String,
446    pub challenged_role: String,
447    pub workflow: BrowserWorkflowPlan,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub why_this_confirms: Option<String>,
450}
451
452#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
453pub struct DifferentialHttpPlan {
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub hypothesis: Option<String>,
456    #[serde(default)]
457    pub steps: Vec<LiveHttpRequest>,
458    #[serde(default, skip_serializing_if = "Vec::is_empty")]
459    pub benign_steps: Vec<LiveHttpRequest>,
460    pub oracle: DifferentialOracle,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub why_this_confirms: Option<String>,
463}
464
465#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
466pub struct BrowserStep {
467    pub action: String,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub url: Option<String>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub selector: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub text: Option<String>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub value: Option<String>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub key: Option<String>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub timeout_ms: Option<u64>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub ms: Option<u64>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub full_page: Option<bool>,
484}
485
486#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
487pub struct BrowserOracle {
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub text_contains: Vec<String>,
490    #[serde(default, skip_serializing_if = "Vec::is_empty")]
491    pub html_contains: Vec<String>,
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub body_not_contains: Vec<String>,
494    #[serde(default, skip_serializing_if = "Vec::is_empty")]
495    pub selector_exists: Vec<String>,
496    #[serde(default, skip_serializing_if = "Vec::is_empty")]
497    pub selector_text_contains: Vec<serde_json::Value>,
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub url_contains: Vec<String>,
500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
501    pub title_contains: Vec<String>,
502    #[serde(default, skip_serializing_if = "Vec::is_empty")]
503    pub console_contains: Vec<String>,
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub alert_contains: Vec<String>,
506    #[serde(default, skip_serializing_if = "Vec::is_empty")]
507    pub dialog_contains: Vec<String>,
508}
509
510impl BrowserOracle {
511    pub fn has_positive_evidence(&self) -> bool {
512        [
513            &self.text_contains,
514            &self.html_contains,
515            &self.selector_exists,
516            &self.url_contains,
517            &self.title_contains,
518            &self.console_contains,
519            &self.alert_contains,
520            &self.dialog_contains,
521        ]
522        .iter()
523        .any(|items| items.iter().any(|s| !s.trim().is_empty()))
524            || !self.selector_text_contains.is_empty()
525    }
526}
527
528#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
529pub struct BrowserWorkflowPlan {
530    pub url: String,
531    #[serde(default, rename = "as", alias = "role")]
532    pub role: String,
533    #[serde(default)]
534    pub steps: Vec<BrowserStep>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub baseline: Option<Box<BrowserWorkflowPlan>>,
537    pub oracle: BrowserOracle,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub payload: Option<ContextualPayload>,
540    #[serde(default, skip_serializing_if = "is_false")]
541    pub state_changing: bool,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub why_this_confirms: Option<String>,
544}
545
546#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
547pub struct NoPlanLiveTestPlan {
548    pub no_plan_reason: NoPlanReason,
549}
550
551#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
552#[allow(clippy::large_enum_variant)]
553#[serde(tag = "kind", rename_all = "snake_case")]
554pub enum LiveTestPlan {
555    #[serde(rename = "single_http", alias = "http")]
556    SingleHttp(SingleHttpPlan),
557    #[serde(rename = "http_workflow", alias = "multi_step_http")]
558    HttpWorkflow(HttpWorkflowPlan),
559    DifferentialHttp(DifferentialHttpPlan),
560    #[serde(rename = "authz_role_comparison", alias = "role_comparison")]
561    AuthzRoleComparison(AuthzRoleComparisonPlan),
562    #[serde(rename = "authz_object_ownership", alias = "object_ownership")]
563    AuthzObjectOwnership(AuthzObjectOwnershipPlan),
564    #[serde(rename = "authz_browser_role_comparison", alias = "browser_role_comparison")]
565    AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan),
566    #[serde(rename = "browser_workflow", alias = "browser")]
567    BrowserWorkflow(BrowserWorkflowPlan),
568    #[serde(rename = "no_plan")]
569    NoPlan(NoPlanLiveTestPlan),
570}
571
572impl LiveTestPlan {
573    pub fn kind_str(&self) -> &'static str {
574        match self {
575            Self::SingleHttp(_) => "single_http",
576            Self::HttpWorkflow(_) => "http_workflow",
577            Self::DifferentialHttp(_) => "differential_http",
578            Self::AuthzRoleComparison(_) => "authz_role_comparison",
579            Self::AuthzObjectOwnership(_) => "authz_object_ownership",
580            Self::AuthzBrowserRoleComparison(_) => "authz_browser_role_comparison",
581            Self::BrowserWorkflow(_) => "browser_workflow",
582            Self::NoPlan(_) => "no_plan",
583        }
584    }
585
586    pub fn no_plan(reason: NoPlanReason) -> Self {
587        Self::NoPlan(NoPlanLiveTestPlan { no_plan_reason: reason })
588    }
589
590    pub fn no_plan_reason(&self) -> Option<&NoPlanReason> {
591        match self {
592            Self::NoPlan(plan) => Some(&plan.no_plan_reason),
593            _ => None,
594        }
595    }
596
597    pub fn validate(&self) -> Result<(), LivePlanValidationError> {
598        match self {
599            Self::SingleHttp(plan) => {
600                validate_http_request(&plan.request)?;
601                validate_optional_http_request(plan.baseline.as_ref())?;
602                validate_optional_http_request(plan.benign.as_ref())?;
603                validate_payload(plan.request.payload.as_ref())?;
604                if !plan.oracle.has_positive_evidence() {
605                    return Err(LivePlanValidationError::WeakOracle(
606                        "single_http plans require body_contains or header_contains positive evidence"
607                            .to_string(),
608                    ));
609                }
610            }
611            Self::HttpWorkflow(plan) => {
612                if plan.steps.is_empty() {
613                    return Err(LivePlanValidationError::MissingField("steps".to_string()));
614                }
615                for step in &plan.steps {
616                    validate_http_request(step)?;
617                    validate_payload(step.payload.as_ref())?;
618                }
619                for step in &plan.benign_steps {
620                    validate_http_request(step)?;
621                    validate_payload(step.payload.as_ref())?;
622                }
623                for step in &plan.cleanup_steps {
624                    validate_http_request(step)?;
625                    validate_payload(step.payload.as_ref())?;
626                }
627                let step_oracles_have_evidence = plan
628                    .steps
629                    .iter()
630                    .chain(plan.benign_steps.iter())
631                    .chain(plan.cleanup_steps.iter())
632                    .filter_map(|step| step.oracle.as_ref())
633                    .any(HttpOracle::has_positive_evidence);
634                if !plan.oracle.has_positive_evidence() && !step_oracles_have_evidence {
635                    return Err(LivePlanValidationError::WeakOracle(
636                        "http_workflow plans require workflow or step body/header positive evidence"
637                            .to_string(),
638                    ));
639                }
640                if let Some(recipe) = &plan.recipe {
641                    if recipe.id.trim().is_empty() {
642                        return Err(LivePlanValidationError::MissingField("recipe.id".to_string()));
643                    }
644                    let mut has_mutation = false;
645                    let mut has_cleanup = !plan.cleanup_steps.is_empty();
646                    let mut has_required_purpose = false;
647                    for step in plan
648                        .steps
649                        .iter()
650                        .chain(plan.benign_steps.iter())
651                        .chain(plan.cleanup_steps.iter())
652                    {
653                        match step.purpose {
654                            Some(HttpWorkflowStepPurpose::Seed)
655                            | Some(HttpWorkflowStepPurpose::StateTransition)
656                            | Some(HttpWorkflowStepPurpose::Exploit)
657                            | Some(HttpWorkflowStepPurpose::Cleanup) => {
658                                has_mutation |= step.destructive
659                                    || !matches!(step.method.as_str(), "GET" | "HEAD" | "OPTIONS");
660                            }
661                            _ => {}
662                        }
663                        has_cleanup |= step.purpose == Some(HttpWorkflowStepPurpose::Cleanup);
664                        has_required_purpose |= matches!(
665                            step.purpose,
666                            Some(HttpWorkflowStepPurpose::Seed)
667                                | Some(HttpWorkflowStepPurpose::PositiveControl)
668                                | Some(HttpWorkflowStepPurpose::StateTransition)
669                                | Some(HttpWorkflowStepPurpose::Exploit)
670                                | Some(HttpWorkflowStepPurpose::Postcondition)
671                        );
672                    }
673                    if !has_required_purpose {
674                        return Err(LivePlanValidationError::MissingField(
675                            "recipe step purpose".to_string(),
676                        ));
677                    }
678                    if has_mutation
679                        && recipe.reset_required != Some(true)
680                        && recipe.cleanup_required != Some(true)
681                        && !has_cleanup
682                    {
683                        return Err(LivePlanValidationError::InvalidField(
684                            "mutating stateful recipes require reset_required or cleanup_steps"
685                                .to_string(),
686                        ));
687                    }
688                }
689            }
690            Self::DifferentialHttp(plan) => {
691                if plan.steps.len() < 2 {
692                    return Err(LivePlanValidationError::MissingField(
693                        "at least two differential steps".to_string(),
694                    ));
695                }
696                for step in &plan.steps {
697                    validate_http_request(step)?;
698                    validate_payload(step.payload.as_ref())?;
699                }
700                for step in &plan.benign_steps {
701                    validate_http_request(step)?;
702                    validate_payload(step.payload.as_ref())?;
703                }
704                if !plan.oracle.has_positive_evidence() {
705                    return Err(LivePlanValidationError::WeakOracle(
706                        "differential_http plans require sensitive_body_markers".to_string(),
707                    ));
708                }
709            }
710            Self::AuthzRoleComparison(plan) => {
711                validate_role_name("allowed_role", &plan.allowed_role)?;
712                validate_role_name("challenged_role", &plan.challenged_role)?;
713                if plan.allowed_role == plan.challenged_role {
714                    return Err(LivePlanValidationError::InvalidField(
715                        "allowed_role and challenged_role must be different".to_string(),
716                    ));
717                }
718                validate_http_request(&plan.request)?;
719                for step in &plan.setup_steps {
720                    validate_http_request(step)?;
721                    validate_payload(step.payload.as_ref())?;
722                }
723                for step in &plan.benign_steps {
724                    validate_http_request(step)?;
725                    validate_payload(step.payload.as_ref())?;
726                }
727                validate_payload(plan.request.payload.as_ref())?;
728                if !plan.oracle.has_positive_evidence() {
729                    return Err(LivePlanValidationError::WeakOracle(
730                        "authz_role_comparison plans require positive_markers".to_string(),
731                    ));
732                }
733            }
734            Self::AuthzObjectOwnership(plan) => {
735                validate_role_name("object.owner_role", &plan.object.owner_role)?;
736                validate_role_name("accessor_role", &plan.accessor_role)?;
737                if plan.object.owner_role == plan.accessor_role {
738                    return Err(LivePlanValidationError::InvalidField(
739                        "object.owner_role and accessor_role must be different".to_string(),
740                    ));
741                }
742                if plan.object.name.trim().is_empty() {
743                    return Err(LivePlanValidationError::MissingField("object.name".to_string()));
744                }
745                validate_http_request(&plan.owner_request)?;
746                validate_payload(plan.owner_request.payload.as_ref())?;
747                validate_http_request(&plan.accessor_request)?;
748                validate_payload(plan.accessor_request.payload.as_ref())?;
749                for step in &plan.seed_steps {
750                    validate_http_request(step)?;
751                    validate_payload(step.payload.as_ref())?;
752                }
753                for step in &plan.benign_steps {
754                    validate_http_request(step)?;
755                    validate_payload(step.payload.as_ref())?;
756                }
757                if !plan.oracle.has_positive_evidence()
758                    && plan.object.positive_markers.iter().all(|s| s.trim().is_empty())
759                    && plan.object.id.as_deref().is_none_or(|s| s.trim().is_empty())
760                {
761                    return Err(LivePlanValidationError::WeakOracle(
762                        "authz_object_ownership plans require positive object markers".to_string(),
763                    ));
764                }
765            }
766            Self::AuthzBrowserRoleComparison(plan) => {
767                validate_role_name("allowed_role", &plan.allowed_role)?;
768                validate_role_name("challenged_role", &plan.challenged_role)?;
769                if plan.allowed_role == plan.challenged_role {
770                    return Err(LivePlanValidationError::InvalidField(
771                        "allowed_role and challenged_role must be different".to_string(),
772                    ));
773                }
774                if plan.workflow.url.trim().is_empty() {
775                    return Err(LivePlanValidationError::MissingField("workflow.url".to_string()));
776                }
777                if !plan.workflow.oracle.has_positive_evidence() {
778                    return Err(LivePlanValidationError::WeakOracle(
779                        "authz_browser_role_comparison plans require a positive browser oracle"
780                            .to_string(),
781                    ));
782                }
783                validate_payload(plan.workflow.payload.as_ref())?;
784            }
785            Self::BrowserWorkflow(plan) => {
786                if plan.url.trim().is_empty() {
787                    return Err(LivePlanValidationError::MissingField("url".to_string()));
788                }
789                if !plan.oracle.has_positive_evidence() {
790                    return Err(LivePlanValidationError::WeakOracle(
791                        "browser_workflow plans require a DOM/browser positive oracle".to_string(),
792                    ));
793                }
794                validate_payload(plan.payload.as_ref())?;
795            }
796            Self::NoPlan(plan) => {
797                if plan.no_plan_reason.message.trim().is_empty() {
798                    return Err(LivePlanValidationError::MissingField(
799                        "no_plan_reason.message".to_string(),
800                    ));
801                }
802            }
803        }
804        Ok(())
805    }
806}
807
808fn validate_optional_http_request(
809    request: Option<&LiveHttpRequest>,
810) -> Result<(), LivePlanValidationError> {
811    if let Some(request) = request {
812        validate_http_request(request)?;
813    }
814    Ok(())
815}
816
817fn validate_http_request(request: &LiveHttpRequest) -> Result<(), LivePlanValidationError> {
818    if request.method.trim().is_empty() {
819        return Err(LivePlanValidationError::MissingField("method".to_string()));
820    }
821    if request.url.trim().is_empty() {
822        return Err(LivePlanValidationError::MissingField("url".to_string()));
823    }
824    Ok(())
825}
826
827fn validate_role_name(field: &str, role: &str) -> Result<(), LivePlanValidationError> {
828    if role.trim().is_empty() {
829        Err(LivePlanValidationError::MissingField(field.to_string()))
830    } else {
831        Ok(())
832    }
833}
834
835fn validate_payload(payload: Option<&ContextualPayload>) -> Result<(), LivePlanValidationError> {
836    if let Some(payload) = payload {
837        payload.validate().map_err(LivePlanValidationError::Payload)?;
838    }
839    Ok(())
840}
841
842#[derive(Clone, Debug, PartialEq, Eq)]
843pub enum LivePlanValidationError {
844    MissingField(String),
845    InvalidField(String),
846    WeakOracle(String),
847    Payload(PayloadValidationError),
848}
849
850impl fmt::Display for LivePlanValidationError {
851    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
852        match self {
853            Self::MissingField(field) => write!(f, "live test plan missing {field}"),
854            Self::InvalidField(reason) => write!(f, "invalid live test plan field: {reason}"),
855            Self::WeakOracle(reason) => write!(f, "weak live test oracle: {reason}"),
856            Self::Payload(err) => write!(f, "invalid payload: {err}"),
857        }
858    }
859}
860
861impl std::error::Error for LivePlanValidationError {}
862
863fn is_false(value: &bool) -> bool {
864    !*value
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn single_http_plan_roundtrips_with_typed_kind() {
873        let plan = LiveTestPlan::SingleHttp(SingleHttpPlan {
874            hypothesis: Some("debug endpoint leaks diagnostic marker".to_string()),
875            request: LiveHttpRequest::get("http://localhost:3000/api/debug"),
876            baseline: Some(LiveHttpRequest::get("http://localhost:3000/")),
877            benign: None,
878            oracle: HttpOracle {
879                body_contains: vec!["debug".to_string()],
880                ..HttpOracle::default()
881            },
882            why_this_confirms: Some(
883                "debug marker is only expected on the sensitive endpoint".to_string(),
884            ),
885        });
886        plan.validate().unwrap();
887        let raw = serde_json::to_string(&plan).unwrap();
888        assert!(raw.contains("\"kind\":\"single_http\""));
889        let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
890        assert_eq!(back.kind_str(), "single_http");
891    }
892
893    #[test]
894    fn no_plan_carries_structured_reason() {
895        let plan = LiveTestPlan::no_plan(
896            NoPlanReason::new(
897                NoPlanReasonCode::RouteNotInferred,
898                "could not map source file to route",
899            )
900            .with_context("path", "src/handlers/admin.rs"),
901        );
902        plan.validate().unwrap();
903        assert_eq!(plan.no_plan_reason().unwrap().code.as_str(), "route_not_inferred");
904    }
905
906    #[test]
907    fn env_capability_report_tracks_auth_and_fixture_readiness() {
908        let report = EnvCapabilityReport {
909            target_reachable: EnvCapabilityStatus::Available,
910            target_urls: vec!["http://localhost:3000".to_string()],
911            browser: EnvCapabilityStatus::Missing,
912            seed: EnvCapabilityStatus::Available,
913            reset: EnvCapabilityStatus::Missing,
914            mailbox: EnvCapabilityStatus::Missing,
915            state_changing: EnvCapabilityStatus::Disabled,
916            exploit_mode_enabled: false,
917            dry_run: false,
918            auth_roles: vec![AuthRoleCapability {
919                role: "user_a".to_string(),
920                mode: "header_injection".to_string(),
921                status: EnvCapabilityStatus::Missing,
922                missing_env_vars: vec!["NYX_AGENT_USER_A_TOKEN".to_string()],
923                missing_artifacts: Vec::new(),
924                notes: vec!["missing env vars: NYX_AGENT_USER_A_TOKEN".to_string()],
925            }],
926            usable_auth_role_pairs: Vec::new(),
927            owned_objects: vec![OwnedObjectCapability {
928                role: "user_a".to_string(),
929                name: "project".to_string(),
930                id: "proj-1".to_string(),
931                route: Some("/api/projects/{id}".to_string()),
932                marker: None,
933            }],
934            findings: Vec::new(),
935        };
936
937        assert!(!report.auth_role_ready("user_a"));
938        assert!(report.auth_role_ready("anonymous"));
939        assert!(report.has_owned_object_for_role("user_a"));
940        assert_eq!(report.missing_auth_roles(["user_a"].into_iter()).len(), 1);
941    }
942
943    #[test]
944    fn differential_requires_positive_marker() {
945        let plan = LiveTestPlan::DifferentialHttp(DifferentialHttpPlan {
946            hypothesis: None,
947            steps: vec![
948                LiveHttpRequest::get("http://localhost:3000/api/accounts/1"),
949                LiveHttpRequest::get("http://localhost:3000/api/accounts/1"),
950            ],
951            benign_steps: Vec::new(),
952            oracle: DifferentialOracle {
953                sensitive_body_markers: Vec::new(),
954                ..DifferentialOracle {
955                    oracle_type: "forbidden_equivalence_break".to_string(),
956                    expected_allowed_step: 0,
957                    expected_forbidden_step: 1,
958                    forbidden_status: vec![401, 403, 404],
959                    sensitive_body_markers: Vec::new(),
960                }
961            },
962            why_this_confirms: None,
963        });
964        assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
965    }
966
967    #[test]
968    fn authz_object_ownership_requires_positive_marker() {
969        let plan = LiveTestPlan::AuthzObjectOwnership(AuthzObjectOwnershipPlan {
970            hypothesis: Some("peer reads owner project".to_string()),
971            object: AuthzOwnedObject {
972                name: "project".to_string(),
973                owner_role: "user_a".to_string(),
974                id: None,
975                id_var: Some("object_id".to_string()),
976                route: Some("/api/projects/{id}".to_string()),
977                positive_markers: Vec::new(),
978            },
979            accessor_role: "user_b".to_string(),
980            seed_steps: Vec::new(),
981            owner_request: LiveHttpRequest::get("http://localhost:3000/api/projects/proj-1"),
982            accessor_request: LiveHttpRequest::get("http://localhost:3000/api/projects/proj-1"),
983            benign_steps: Vec::new(),
984            oracle: AuthzOracle::object_ownership(Vec::new()),
985            why_this_confirms: None,
986        });
987        assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
988    }
989
990    #[test]
991    fn authz_role_comparison_roundtrips_with_markers() {
992        let plan = LiveTestPlan::AuthzRoleComparison(AuthzRoleComparisonPlan {
993            hypothesis: Some("user can read admin report".to_string()),
994            allowed_role: "admin".to_string(),
995            challenged_role: "user".to_string(),
996            request: LiveHttpRequest::get("http://localhost:3000/api/admin/report"),
997            setup_steps: Vec::new(),
998            benign_steps: Vec::new(),
999            oracle: AuthzOracle::role_comparison(vec!["admin-report".to_string()]),
1000            why_this_confirms: Some(
1001                "the user role receives the same admin marker as admin".to_string(),
1002            ),
1003        });
1004        plan.validate().unwrap();
1005        let raw = serde_json::to_string(&plan).unwrap();
1006        assert!(raw.contains("\"kind\":\"authz_role_comparison\""));
1007        let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
1008        assert_eq!(back.kind_str(), "authz_role_comparison");
1009    }
1010
1011    #[test]
1012    fn authz_browser_role_comparison_requires_positive_oracle() {
1013        let plan = LiveTestPlan::AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan {
1014            hypothesis: Some("user can view admin panel".to_string()),
1015            allowed_role: "admin".to_string(),
1016            challenged_role: "user".to_string(),
1017            workflow: BrowserWorkflowPlan {
1018                url: "http://localhost:3000/admin".to_string(),
1019                role: String::new(),
1020                steps: Vec::new(),
1021                baseline: None,
1022                oracle: BrowserOracle::default(),
1023                payload: None,
1024                state_changing: false,
1025                why_this_confirms: None,
1026            },
1027            why_this_confirms: None,
1028        });
1029
1030        assert!(matches!(plan.validate(), Err(LivePlanValidationError::WeakOracle(_))));
1031    }
1032
1033    #[test]
1034    fn authz_browser_role_comparison_roundtrips_with_marker() {
1035        let plan = LiveTestPlan::AuthzBrowserRoleComparison(AuthzBrowserRoleComparisonPlan {
1036            hypothesis: Some("user can view admin panel".to_string()),
1037            allowed_role: "admin".to_string(),
1038            challenged_role: "user".to_string(),
1039            workflow: BrowserWorkflowPlan {
1040                url: "http://localhost:3000/admin".to_string(),
1041                role: String::new(),
1042                steps: Vec::new(),
1043                baseline: None,
1044                oracle: BrowserOracle {
1045                    text_contains: vec!["Admin Console".to_string()],
1046                    ..BrowserOracle::default()
1047                },
1048                payload: None,
1049                state_changing: false,
1050                why_this_confirms: None,
1051            },
1052            why_this_confirms: None,
1053        });
1054
1055        plan.validate().unwrap();
1056        let raw = serde_json::to_string(&plan).unwrap();
1057        assert!(raw.contains("\"kind\":\"authz_browser_role_comparison\""));
1058        let back: LiveTestPlan = serde_json::from_str(&raw).unwrap();
1059        assert_eq!(back.kind_str(), "authz_browser_role_comparison");
1060    }
1061}