diff --git a/Cargo.lock b/Cargo.lock index a670d2b..a24552b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4149,6 +4149,7 @@ version = "0.2.0" dependencies = [ "anyhow", "chrono", + "clap", "filetime", "regex", "rowan", diff --git a/crates/weft-compiler/Cargo.toml b/crates/weft-compiler/Cargo.toml index d5a4c7d..aa1c917 100644 --- a/crates/weft-compiler/Cargo.toml +++ b/crates/weft-compiler/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true repository.workspace = true +[[bin]] +name = "weft-codex-consult" +path = "src/bin/weft_codex_consult.rs" + [dependencies] # weft-core's `runtime` feature is enabled ONLY by our `build` feature (which # needs the runtime `Node`/`NodeCatalog` types for codegen). The parse/validate @@ -38,6 +42,7 @@ regex = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +clap = { workspace = true } # Native-only; used by the codegen mtime-preservation path. Gated behind `build`. filetime = { workspace = true, optional = true } # Temp dir for `seed_catalog_into_upload` (server-side create: materialize the diff --git a/crates/weft-compiler/src/bin/weft_codex_consult.rs b/crates/weft-compiler/src/bin/weft_codex_consult.rs new file mode 100644 index 0000000..2e88547 --- /dev/null +++ b/crates/weft-compiler/src/bin/weft_codex_consult.rs @@ -0,0 +1,135 @@ +use anyhow::{Context, bail}; +use clap::{Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; +use uuid::Uuid; +use weft_compiler::codex_consultation::{ + ConsultationCheck, ConsultationContext, ConsultationPacket, ImplementationPhase, + validate_response_json, +}; + +#[derive(Debug, Parser)] +#[command( + name = "weft-codex-consult", + about = "Offline, read-only bridge for governed Codex implementation consultation" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Emit a deterministic consultation packet to stdout. Performs no external calls. + Emit { + #[arg(long)] + project_id: Uuid, + #[arg(long)] + source_sha256: String, + #[arg(long)] + definition_sha256: String, + #[arg(long)] + source_path: String, + #[arg(long)] + definition_path: String, + #[arg(long)] + phase: PhaseArg, + #[arg(long)] + prompt_contract_version: String, + #[arg(long = "check", required = true)] + checks: Vec, + }, + /// Verify a Codex response and emit a non-executing validation receipt. + Verify { + #[arg(long)] + request: PathBuf, + #[arg(long)] + response: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum PhaseArg { + Design, + Generate, + Validate, +} + +impl From for ImplementationPhase { + fn from(value: PhaseArg) -> Self { + match value { + PhaseArg::Design => Self::Design, + PhaseArg::Generate => Self::Generate, + PhaseArg::Validate => Self::Validate, + } + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CheckArg { + Correctness, + Evidence, + Policy, + Security, +} + +impl From for ConsultationCheck { + fn from(value: CheckArg) -> Self { + match value { + CheckArg::Correctness => Self::Correctness, + CheckArg::Evidence => Self::Evidence, + CheckArg::Policy => Self::Policy, + CheckArg::Security => Self::Security, + } + } +} + +fn main() -> anyhow::Result<()> { + match Cli::parse().command { + Command::Emit { + project_id, + source_sha256, + definition_sha256, + source_path, + definition_path, + phase, + prompt_contract_version, + checks, + } => { + if checks.is_empty() { + bail!("at least one --check is required"); + } + let packet = ConsultationPacket::new(ConsultationContext { + project_id, + source_sha256, + definition_sha256, + source_path, + definition_path, + implementation_phase: phase.into(), + prompt_contract_version, + requested_checks: checks.into_iter().map(Into::into).collect(), + }) + .context("build consultation packet")?; + println!("{}", packet.to_canonical_json()?); + Ok(()) + } + Command::Verify { request, response } => { + let request_json = std::fs::read_to_string(&request) + .with_context(|| format!("read consultation request {}", request.display()))?; + let response_json = std::fs::read_to_string(&response) + .with_context(|| format!("read consultation response {}", response.display()))?; + let packet = ConsultationPacket::from_json(&request_json) + .context("validate consultation request")?; + let validated = validate_response_json(&packet, &response_json) + .context("validate consultation response")?; + let receipt = serde_json::json!({ + "outcome": "verified_advisory", + "status": validated.response.status, + "request_sha256": packet.request_sha256, + "response_sha256": validated.response_sha256, + "executed": false + }); + println!("{}", serde_json::to_string(&receipt)?); + Ok(()) + } + } +} diff --git a/crates/weft-compiler/src/codex_consultation.rs b/crates/weft-compiler/src/codex_consultation.rs new file mode 100644 index 0000000..9eb2739 --- /dev/null +++ b/crates/weft-compiler/src/codex_consultation.rs @@ -0,0 +1,412 @@ +//! Deterministic, offline packets for implementation-time Codex consultation. +//! +//! This module deliberately performs no model, process, filesystem, or network +//! calls. It binds bounded compiler context into a read-only request that an +//! external governed Codex adapter may consume. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +pub const CONSULTATION_SCHEMA_VERSION: u32 = 1; +pub const CONSULTATION_PROTOCOL: &str = "codex-consultation-v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsultationCheck { + Correctness, + Evidence, + Policy, + Security, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImplementationPhase { + Design, + Generate, + Validate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsultationContext { + pub project_id: Uuid, + pub source_sha256: String, + pub definition_sha256: String, + pub source_path: String, + pub definition_path: String, + pub implementation_phase: ImplementationPhase, + pub prompt_contract_version: String, + pub requested_checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationConstraints { + pub read_only: bool, + pub no_canonical_writes: bool, + pub no_external_side_effects: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationRequest { + pub schema_version: u32, + pub project_id: Uuid, + pub source_sha256: String, + pub definition_sha256: String, + pub source_path: String, + pub definition_path: String, + pub implementation_phase: ImplementationPhase, + pub prompt_contract_version: String, + pub requested_checks: Vec, + pub constraints: ConsultationConstraints, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationPacket { + pub schema_version: u32, + pub protocol: String, + pub request_sha256: String, + pub request: ConsultationRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsultationStatus { + Advice, + Abstain, + Blocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationFinding { + pub code: String, + pub severity: FindingSeverity, + pub message: String, + pub evidence_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationProvenance { + pub producer_kind: String, + pub provider: String, + pub model: String, + pub role: String, + pub policy_sha256: String, + pub prompt_sha256: String, + pub execution_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsultationResponse { + pub schema_version: u32, + pub protocol: String, + pub request_sha256: String, + pub status: ConsultationStatus, + pub findings: Vec, + pub provenance: ConsultationProvenance, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedConsultation { + pub response: ConsultationResponse, + pub response_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnavailableReason { + NotConfigured, + AdapterUnavailable, + Timeout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotConsultedReason { + Disabled, + NotConfigured, + AdapterUnavailable, + Timeout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsultationInput<'a> { + Disabled, + Unavailable(UnavailableReason), + Response(&'a str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConsultationDisposition { + NotConsulted(NotConsultedReason), + Validated(Box), +} + +#[derive(Debug, Error)] +pub enum ConsultationError { + #[error("{field} must be a lowercase 64-character SHA-256 digest")] + InvalidDigest { field: &'static str }, + #[error( + "prompt contract version must use 1-64 lowercase ASCII letters, digits, '.', '_', or '-'" + )] + InvalidPromptContractVersion, + #[error("{field} must be a normalized repository-relative path")] + InvalidRelativePath { field: &'static str }, + #[error("at least one consultation check is required")] + EmptyChecks, + #[error("unsupported consultation schema version or protocol")] + UnsupportedProtocol, + #[error("consultation packet request digest does not verify")] + InvalidPacketDigest, + #[error( + "consultation packet constraints must remain read-only with no canonical writes or external side effects" + )] + WeakenedConstraints, + #[error("consultation response is bound to a different request")] + RequestDigestMismatch, + #[error("consultation response provenance is invalid: {0}")] + InvalidProvenance(&'static str), + #[error("advice response must contain at least one finding")] + EmptyAdvice, + #[error("blocked response must contain at least one finding")] + EmptyBlocked, + #[error("abstain response must not contain findings")] + AbstainWithFindings, + #[error("consultation finding is invalid: {0}")] + InvalidFinding(&'static str), + #[error("consultation JSON serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +impl ConsultationPacket { + pub fn new(context: ConsultationContext) -> Result { + validate_sha256("source_sha256", &context.source_sha256)?; + validate_sha256("definition_sha256", &context.definition_sha256)?; + validate_relative_path("source_path", &context.source_path)?; + validate_relative_path("definition_path", &context.definition_path)?; + validate_contract_version(&context.prompt_contract_version)?; + if context.requested_checks.is_empty() { + return Err(ConsultationError::EmptyChecks); + } + + let mut requested_checks = context.requested_checks; + requested_checks.sort_unstable(); + requested_checks.dedup(); + let request = ConsultationRequest { + schema_version: CONSULTATION_SCHEMA_VERSION, + project_id: context.project_id, + source_sha256: context.source_sha256, + definition_sha256: context.definition_sha256, + source_path: context.source_path, + definition_path: context.definition_path, + implementation_phase: context.implementation_phase, + prompt_contract_version: context.prompt_contract_version, + requested_checks, + constraints: ConsultationConstraints { + read_only: true, + no_canonical_writes: true, + no_external_side_effects: true, + }, + }; + let request_bytes = serde_json::to_vec(&request)?; + let request_sha256 = sha256_hex(&request_bytes); + Ok(Self { + schema_version: CONSULTATION_SCHEMA_VERSION, + protocol: CONSULTATION_PROTOCOL.to_string(), + request_sha256, + request, + }) + } + + pub fn to_canonical_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } + + pub fn from_json(json: &str) -> Result { + let packet: Self = serde_json::from_str(json)?; + packet.validate_integrity()?; + Ok(packet) + } + + fn validate_integrity(&self) -> Result<(), ConsultationError> { + if self.schema_version != CONSULTATION_SCHEMA_VERSION + || self.protocol != CONSULTATION_PROTOCOL + || self.request.schema_version != CONSULTATION_SCHEMA_VERSION + { + return Err(ConsultationError::UnsupportedProtocol); + } + let constraints = &self.request.constraints; + if !constraints.read_only + || !constraints.no_canonical_writes + || !constraints.no_external_side_effects + { + return Err(ConsultationError::WeakenedConstraints); + } + validate_sha256("source_sha256", &self.request.source_sha256)?; + validate_sha256("definition_sha256", &self.request.definition_sha256)?; + validate_relative_path("source_path", &self.request.source_path)?; + validate_relative_path("definition_path", &self.request.definition_path)?; + validate_contract_version(&self.request.prompt_contract_version)?; + if self.request.requested_checks.is_empty() { + return Err(ConsultationError::EmptyChecks); + } + let expected_request_sha256 = sha256_hex(&serde_json::to_vec(&self.request)?); + if self.request_sha256 != expected_request_sha256 { + return Err(ConsultationError::InvalidPacketDigest); + } + Ok(()) + } +} + +pub fn evaluate_consultation( + packet: &ConsultationPacket, + input: ConsultationInput<'_>, +) -> Result { + packet.validate_integrity()?; + let disposition = match input { + ConsultationInput::Disabled => { + ConsultationDisposition::NotConsulted(NotConsultedReason::Disabled) + } + ConsultationInput::Unavailable(reason) => { + let reason = match reason { + UnavailableReason::NotConfigured => NotConsultedReason::NotConfigured, + UnavailableReason::AdapterUnavailable => NotConsultedReason::AdapterUnavailable, + UnavailableReason::Timeout => NotConsultedReason::Timeout, + }; + ConsultationDisposition::NotConsulted(reason) + } + ConsultationInput::Response(response_json) => ConsultationDisposition::Validated(Box::new( + validate_response_json(packet, response_json)?, + )), + }; + Ok(disposition) +} + +pub fn validate_response_json( + packet: &ConsultationPacket, + response_json: &str, +) -> Result { + packet.validate_integrity()?; + + let response: ConsultationResponse = serde_json::from_str(response_json)?; + if response.schema_version != CONSULTATION_SCHEMA_VERSION + || response.protocol != CONSULTATION_PROTOCOL + { + return Err(ConsultationError::UnsupportedProtocol); + } + if response.request_sha256 != packet.request_sha256 { + return Err(ConsultationError::RequestDigestMismatch); + } + validate_response_content(&response)?; + + Ok(ValidatedConsultation { + response, + response_sha256: sha256_hex(response_json.as_bytes()), + }) +} + +fn validate_response_content(response: &ConsultationResponse) -> Result<(), ConsultationError> { + let provenance = &response.provenance; + if provenance.producer_kind != "model" { + return Err(ConsultationError::InvalidProvenance( + "producer_kind must be model", + )); + } + for (value, label) in [ + (&provenance.provider, "provider is empty"), + (&provenance.model, "model is empty"), + (&provenance.role, "role is empty"), + (&provenance.execution_id, "execution_id is empty"), + ] { + if value.trim().is_empty() { + return Err(ConsultationError::InvalidProvenance(label)); + } + } + validate_sha256("policy_sha256", &provenance.policy_sha256) + .map_err(|_| ConsultationError::InvalidProvenance("policy_sha256 is invalid"))?; + validate_sha256("prompt_sha256", &provenance.prompt_sha256) + .map_err(|_| ConsultationError::InvalidProvenance("prompt_sha256 is invalid"))?; + + if response.status == ConsultationStatus::Advice && response.findings.is_empty() { + return Err(ConsultationError::EmptyAdvice); + } + if response.status == ConsultationStatus::Blocked && response.findings.is_empty() { + return Err(ConsultationError::EmptyBlocked); + } + if response.status == ConsultationStatus::Abstain && !response.findings.is_empty() { + return Err(ConsultationError::AbstainWithFindings); + } + for finding in &response.findings { + if finding.code.trim().is_empty() { + return Err(ConsultationError::InvalidFinding("code is empty")); + } + if finding.message.trim().is_empty() { + return Err(ConsultationError::InvalidFinding("message is empty")); + } + if finding.evidence_ids.iter().any(|id| id.trim().is_empty()) { + return Err(ConsultationError::InvalidFinding("evidence id is empty")); + } + } + Ok(()) +} + +fn validate_sha256(field: &'static str, value: &str) -> Result<(), ConsultationError> { + let valid = value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + if valid { + Ok(()) + } else { + Err(ConsultationError::InvalidDigest { field }) + } +} + +fn validate_relative_path(field: &'static str, value: &str) -> Result<(), ConsultationError> { + let valid = !value.is_empty() + && value.len() <= 240 + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains('\\') + && !value.contains(':') + && !value.chars().any(char::is_control) + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != ".."); + if valid { + Ok(()) + } else { + Err(ConsultationError::InvalidRelativePath { field }) + } +} + +fn validate_contract_version(value: &str) -> Result<(), ConsultationError> { + let valid = !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-') + }); + if valid { + Ok(()) + } else { + Err(ConsultationError::InvalidPromptContractVersion) + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("{digest:x}") +} diff --git a/crates/weft-compiler/src/lib.rs b/crates/weft-compiler/src/lib.rs index 1e8e8b0..f2aefd5 100644 --- a/crates/weft-compiler/src/lib.rs +++ b/crates/weft-compiler/src/lib.rs @@ -21,6 +21,7 @@ pub mod file_reader; pub mod file_ref; pub mod enrich; pub mod validate; +pub mod codex_consultation; // Project source / drift hashing. `compute_definition_hash` / // `compute_source_hash` are pure (no filesystem) and always available // (the browser WASM build computes `definition_hash` for live preview); diff --git a/crates/weft-compiler/tests/codex_consultation.rs b/crates/weft-compiler/tests/codex_consultation.rs new file mode 100644 index 0000000..65fda46 --- /dev/null +++ b/crates/weft-compiler/tests/codex_consultation.rs @@ -0,0 +1,275 @@ +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use weft_compiler::codex_consultation::{ + ConsultationCheck, ConsultationContext, ConsultationDisposition, ConsultationInput, + ConsultationPacket, ConsultationStatus, ImplementationPhase, NotConsultedReason, + UnavailableReason, evaluate_consultation, validate_response_json, +}; + +const SOURCE_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DEFINITION_SHA256: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn context() -> ConsultationContext { + ConsultationContext { + project_id: Uuid::parse_str("018f5f2e-2f08-7f6b-9d47-9c2b19b6a123").unwrap(), + source_sha256: SOURCE_SHA256.to_string(), + definition_sha256: DEFINITION_SHA256.to_string(), + source_path: "crates/weft-compiler/src/codex_consultation.rs".to_string(), + definition_path: "docs/codex-consultation.md".to_string(), + implementation_phase: ImplementationPhase::Validate, + prompt_contract_version: "codex-consultation-v1".to_string(), + requested_checks: vec![ + ConsultationCheck::Security, + ConsultationCheck::Policy, + ConsultationCheck::Evidence, + ], + } +} + +#[test] +fn deterministic_packet_contains_only_bounded_context_and_digests() { + let first = ConsultationPacket::new(context()).unwrap(); + let second = ConsultationPacket::new(context()).unwrap(); + + assert_eq!(first, second); + assert_eq!(first.schema_version, 1); + assert_eq!(first.protocol, "codex-consultation-v1"); + assert_eq!(first.request.schema_version, 1); + assert!(first.request.constraints.read_only); + assert!(first.request.constraints.no_canonical_writes); + assert!(first.request.constraints.no_external_side_effects); + assert_eq!( + first.request.source_path, + "crates/weft-compiler/src/codex_consultation.rs" + ); + assert_eq!(first.request.definition_path, "docs/codex-consultation.md"); + assert_eq!( + first.request.requested_checks, + vec![ + ConsultationCheck::Evidence, + ConsultationCheck::Policy, + ConsultationCheck::Security, + ] + ); + + let json = first.to_canonical_json().unwrap(); + assert_eq!(json, second.to_canonical_json().unwrap()); + assert!(!json.contains("api_key")); + assert!(!json.contains("source_body")); + assert!(!json.contains("prompt_body")); + assert_eq!(first.request_sha256.len(), 64); +} + +#[test] +fn packet_rejects_absolute_and_traversing_source_locators() { + let mut traversing = context(); + traversing.source_path = "../secrets.env".to_string(); + assert!( + ConsultationPacket::new(traversing) + .unwrap_err() + .to_string() + .contains("source_path") + ); + + let mut absolute = context(); + absolute.definition_path = "C:/outside/policy.md".to_string(); + assert!( + ConsultationPacket::new(absolute) + .unwrap_err() + .to_string() + .contains("definition_path") + ); +} + +#[test] +fn response_is_advisory_model_attributed_and_bound_to_request() { + let packet = ConsultationPacket::new(context()).unwrap(); + let response = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "advice", + "findings": [{ + "code": "policy-boundary", + "severity": "warning", + "message": "Keep consultation outside the deterministic compile decision.", + "evidence_ids": ["codex:agents:compiler-boundary"] + }], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-001" + } + }); + + let validated = validate_response_json(&packet, &response.to_string()).unwrap(); + + assert_eq!(validated.response.status, ConsultationStatus::Advice); + assert_eq!(validated.response.findings.len(), 1); + assert_eq!(validated.response_sha256.len(), 64); +} + +#[test] +fn packet_parser_rejects_weakened_read_only_constraints() { + let packet = ConsultationPacket::new(context()).unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&packet.to_canonical_json().unwrap()).unwrap(); + value["request"]["constraints"]["read_only"] = serde_json::Value::Bool(false); + + let error = ConsultationPacket::from_json(&value.to_string()).unwrap_err(); + + assert!(error.to_string().contains("constraints")); +} + +#[test] +fn abstain_cannot_smuggle_findings() { + let packet = ConsultationPacket::new(context()).unwrap(); + let response = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "abstain", + "findings": [{ + "code": "hidden-advice", + "severity": "warning", + "message": "This must not be accepted under abstain.", + "evidence_ids": [] + }], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-002" + } + }); + + let error = validate_response_json(&packet, &response.to_string()).unwrap_err(); + + assert!(error.to_string().contains("abstain")); +} + +#[test] +fn disabled_and_unavailable_modes_have_explicit_deterministic_fallbacks() { + let packet = ConsultationPacket::new(context()).unwrap(); + + let disabled = evaluate_consultation(&packet, ConsultationInput::Disabled).unwrap(); + let timeout = evaluate_consultation( + &packet, + ConsultationInput::Unavailable(UnavailableReason::Timeout), + ) + .unwrap(); + let missing = evaluate_consultation( + &packet, + ConsultationInput::Unavailable(UnavailableReason::NotConfigured), + ) + .unwrap(); + + assert_eq!( + disabled, + ConsultationDisposition::NotConsulted(NotConsultedReason::Disabled) + ); + assert_eq!( + timeout, + ConsultationDisposition::NotConsulted(NotConsultedReason::Timeout) + ); + assert_eq!( + missing, + ConsultationDisposition::NotConsulted(NotConsultedReason::NotConfigured) + ); +} + +#[test] +fn malformed_unknown_and_digest_mismatched_responses_fail_closed() { + let packet = ConsultationPacket::new(context()).unwrap(); + assert!(validate_response_json(&packet, "{not-json").is_err()); + + let base = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "abstain", + "findings": [], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-003" + } + }); + + let mut unknown = base.clone(); + unknown["canonical_write"] = serde_json::Value::Bool(true); + assert!(validate_response_json(&packet, &unknown.to_string()).is_err()); + + let mut mismatched = base; + mismatched["request_sha256"] = serde_json::Value::String( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_string(), + ); + let error = validate_response_json(&packet, &mismatched.to_string()).unwrap_err(); + assert!(error.to_string().contains("different request")); +} + +#[test] +fn response_validation_rejects_digest_consistent_weakened_packet() { + let mut packet = ConsultationPacket::new(context()).unwrap(); + packet.request.constraints.read_only = false; + packet.request_sha256 = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&packet.request).unwrap()) + ); + let response = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "abstain", + "findings": [], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-004" + } + }); + + let error = validate_response_json(&packet, &response.to_string()).unwrap_err(); + + assert!(error.to_string().contains("constraints")); +} + +#[test] +fn blocked_response_requires_explanatory_finding() { + let packet = ConsultationPacket::new(context()).unwrap(); + let response = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "blocked", + "findings": [], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-005" + } + }); + + let error = validate_response_json(&packet, &response.to_string()).unwrap_err(); + + assert!(error.to_string().contains("blocked")); +} diff --git a/crates/weft-compiler/tests/codex_consultation_cli.rs b/crates/weft-compiler/tests/codex_consultation_cli.rs new file mode 100644 index 0000000..833a534 --- /dev/null +++ b/crates/weft-compiler/tests/codex_consultation_cli.rs @@ -0,0 +1,118 @@ +use std::io::Write; +use std::process::Command; + +use tempfile::NamedTempFile; +use uuid::Uuid; +use weft_compiler::codex_consultation::{ + ConsultationCheck, ConsultationContext, ConsultationPacket, ImplementationPhase, +}; + +#[test] +fn emit_prints_offline_consultation_packet_without_runtime() { + let output = Command::new(env!("CARGO_BIN_EXE_weft-codex-consult")) + .args([ + "emit", + "--project-id", + "018f5f2e-2f08-7f6b-9d47-9c2b19b6a123", + "--source-sha256", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--definition-sha256", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "--source-path", + "crates/weft-compiler/src/codex_consultation.rs", + "--definition-path", + "docs/codex-consultation.md", + "--phase", + "validate", + "--prompt-contract-version", + "codex-consultation-v1", + "--check", + "security", + "--check", + "policy", + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["protocol"], "codex-consultation-v1"); + assert_eq!(value["request"]["constraints"]["read_only"], true); + assert_eq!( + value["request"]["requested_checks"], + serde_json::json!(["policy", "security"]) + ); +} + +#[test] +fn verify_prints_receipt_without_executing_advice() { + let packet = ConsultationPacket::new(ConsultationContext { + project_id: Uuid::parse_str("018f5f2e-2f08-7f6b-9d47-9c2b19b6a123").unwrap(), + source_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + definition_sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + source_path: "crates/weft-compiler/src/codex_consultation.rs".to_string(), + definition_path: "docs/codex-consultation.md".to_string(), + implementation_phase: ImplementationPhase::Validate, + prompt_contract_version: "codex-consultation-v1".to_string(), + requested_checks: vec![ConsultationCheck::Policy], + }) + .unwrap(); + let response = serde_json::json!({ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": packet.request_sha256, + "status": "advice", + "findings": [{ + "code": "keep-read-only", + "severity": "warning", + "message": "Do not execute this advice automatically.", + "evidence_ids": ["codex:agents:read-only"] + }], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "prompt_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "execution_id": "consult-001" + } + }); + let mut request_file = NamedTempFile::new().unwrap(); + request_file + .write_all(packet.to_canonical_json().unwrap().as_bytes()) + .unwrap(); + let mut response_file = NamedTempFile::new().unwrap(); + response_file + .write_all(response.to_string().as_bytes()) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_weft-codex-consult")) + .args([ + "verify", + "--request", + request_file.path().to_str().unwrap(), + "--response", + response_file.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let receipt: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(receipt["outcome"], "verified_advisory"); + assert_eq!(receipt["status"], "advice"); + assert_eq!(receipt["request_sha256"], packet.request_sha256); + assert_eq!(receipt["response_sha256"].as_str().unwrap().len(), 64); + assert_eq!(receipt["executed"], false); +} diff --git a/docs/codex-consultation.md b/docs/codex-consultation.md new file mode 100644 index 0000000..4a631ce --- /dev/null +++ b/docs/codex-consultation.md @@ -0,0 +1,92 @@ +# Codex implementation consultation + +Weft can exchange deterministic, read-only implementation-consultation packets with an external governed Codex process. The bridge is optional and offline. It does not call a model, spawn a process, contact a network service, alter compiler output, or write canonical state. + +## Boundary + +- Protocol: `codex-consultation-v1` +- Schema version: `1` +- Request content is bounded to project identity, repository-relative source and definition paths, matching SHA-256 digests, implementation phase, prompt-contract version, requested check enums, and immutable safety constraints. +- Source and definition paths must be normalized repository-relative paths. Absolute paths, drive-qualified paths, backslashes, empty segments, `.` segments, and `..` traversal are rejected. +- Source text, prompt text, credentials, environment variables, and arbitrary operator prose are not request fields. +- Responses are advisory. Weft only validates and receipts them; `executed` is always `false`. +- Response structs reject unknown fields. +- Model advice must include provider, model, role, execution ID, policy digest, and prompt digest. +- `advice` requires findings. `blocked` requires at least one explanatory finding. `abstain` forbids findings. +- A response bound to any other request digest is rejected. + +## Emit a request + +```console +weft-codex-consult emit \ + --project-id 018f5f2e-2f08-7f6b-9d47-9c2b19b6a123 \ + --source-sha256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --definition-sha256 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + --source-path crates/weft-compiler/src/codex_consultation.rs \ + --definition-path docs/codex-consultation.md \ + --phase validate \ + --prompt-contract-version codex-consultation-v1 \ + --check security \ + --check policy > request.json +``` + +The command writes only JSON to stdout. Redirecting stdout is an operator-controlled filesystem action outside the bridge. + +## External governed handoff + +An authorized external Codex runner may consume `request.json`. That runner is responsible for provider routing, timeout enforcement, credential handling, prompt-contract validation, execution attestation, and producing a response matching this closed schema: + +```json +{ + "schema_version": 1, + "protocol": "codex-consultation-v1", + "request_sha256": "", + "status": "advice", + "findings": [ + { + "code": "stable-code", + "severity": "warning", + "message": "Advisory text only.", + "evidence_ids": ["codex:domain:evidence-id"] + } + ], + "provenance": { + "producer_kind": "model", + "provider": "policy-routed", + "model": "model-id", + "role": "implementation_consultant", + "policy_sha256": "<64 lowercase hex characters>", + "prompt_sha256": "<64 lowercase hex characters>", + "execution_id": "consultation-run-id" + } +} +``` + +Allowed statuses are `advice`, `abstain`, and `blocked`. Allowed severities are `info`, `warning`, and `error`. + +## Verify a response + +```console +weft-codex-consult verify --request request.json --response response.json +``` + +On success, stdout contains a compact receipt with: + +- `outcome: "verified_advisory"` +- the response status +- request and response SHA-256 digests +- `executed: false` + +Malformed JSON, unknown fields, weakened constraints, unsupported protocol versions, invalid provenance, and digest mismatch fail closed with a non-zero exit. + +## Deterministic fallback + +Library integrations must use `evaluate_consultation` and select one explicit input: + +- `Disabled` +- `Unavailable(NotConfigured)` +- `Unavailable(AdapterUnavailable)` +- `Unavailable(Timeout)` +- `Response(json)` + +Disabled and unavailable cases return an explicit `NotConsulted` disposition. They do not alter deterministic compilation and are never silently upgraded to a model call. Only `Response(json)` enters response validation.