diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index 1f2c6daaaf..a0ffb46bf6 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -1535,6 +1535,9 @@ mod generation_retention_test; #[cfg(test)] mod configuration_idempotency_journey_test; +#[cfg(test)] +mod configuration_protected_preview_journey_test; + #[cfg(test)] mod read_only_project_open_journey_test; diff --git a/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs new file mode 100644 index 0000000000..74b2191aa4 --- /dev/null +++ b/crates/tracedecay/src/daemon/production_harness/configuration_protected_preview_journey_test.rs @@ -0,0 +1,275 @@ +//! Host-facing behavior of `tracedecay_configuration_protected_preview`. +//! +//! The tool is a dry-run: the answer is a redacted plan bound to the revision +//! the caller supplied, and a wrong revision or an invalid change is a typed +//! problem rather than a committed setting. Callers observe that through MCP +//! `tools/call`, which is the path this journey drives. + +use std::collections::BTreeSet; +use std::path::Path; + +use serde_json::{Value, json}; +use tempfile::TempDir; +use tracedecay_contracts::ConfigurationProtectedPreviewRequestV1; +use tracedecay_domain::configuration::{ + AccessRuleId, AuthorityRef, ConfigurationRevisionId, ProtectedChange, RuleEffect, + ScopeAccessRule, ScopeAccessSubjectV1, SourceBindingId, SourceKindV1, +}; +use tracedecay_domain::{CapabilityId, ManifestDigest}; + +use super::journey_test_support::{git, tool_answer}; +use super::*; + +const ACCESS_RULE_ID: &str = "access-rule.preview-cursor-deny"; +const DENIED_CAPABILITY: &str = "capability.work.generate_proposal"; +const ABSENT_BINDING_ID: &str = "source-binding.preview-absent"; +const STALE_REVISION: &str = "configuration.revision.protected-preview-not-current"; + +fn initialize_project(project: &Path) { + std::fs::create_dir_all(project.join("src")).expect("project source"); + std::fs::write(project.join("src/lib.rs"), "pub fn preview_probe() {}\n") + .expect("project source file"); + git(project, &["init", "--quiet"]); +} + +fn preview_arguments(change: &ProtectedChange, revision: &ConfigurationRevisionId) -> Value { + let mut arguments = serde_json::to_value(ConfigurationProtectedPreviewRequestV1 { + change: change.clone(), + expected_revision: revision.clone(), + }) + .expect("protected preview arguments"); + arguments["format"] = json!("json"); + arguments +} + +fn deny_cursor_work(project_id: tracedecay_domain::ProjectId) -> ProtectedChange { + ProtectedChange::UpsertAccessRule( + ScopeAccessRule::new( + AccessRuleId::new(ACCESS_RULE_ID).expect("access rule identity"), + ScopeAccessSubjectV1 { + actor: None, + operation: None, + source_kind: Some(SourceKindV1::Cursor), + }, + AuthorityRef::Project(project_id), + BTreeSet::from([ + CapabilityId::new(DENIED_CAPABILITY).expect("generate proposal capability") + ]), + RuleEffect::Deny, + None, + ) + .expect("deny-only work rule"), + ) +} + +async fn call_preview( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + arguments: Value, +) -> (bool, Value) { + let response = harness + .call_tool( + project, + "tracedecay_configuration_protected_preview", + arguments, + ) + .await + .expect("protected preview tools/call"); + tool_answer(&response) +} + +fn assert_redacted_plan( + payload: &Value, + revision: &str, + setting_key: &str, + operation: &str, + before_digest: &str, + after_digest: &str, + hidden: &[&str], +) { + assert_eq!(payload["outcome"]["outcome"], "preview"); + assert_eq!( + payload["outcome"]["value"]["effect_class"], + "configuration_write" + ); + let plan = &payload["outcome"]["value"]["payload"]; + assert_eq!(plan["base_revision_id"], revision); + assert_eq!( + plan["redacted_changes"], + json!([{ + "setting_key": setting_key, + "operation": operation, + "before_digest": before_digest, + "after_digest": after_digest, + }]) + ); + assert_eq!(plan["operation_digest"], after_digest); + assert_eq!(payload["outcome"]["value"]["preview_digest"], after_digest); + assert_eq!( + payload["outcome"]["value"]["preview_id"], plan["plan_id"], + "the preview id the host applies is the plan id" + ); + let plan_id = plan["plan_id"].as_str().expect("plan id"); + assert!( + plan_id.starts_with("configuration.plan.v1."), + "plan id {plan_id} is not a configuration plan" + ); + let created_at = plan["created_at"].as_i64().expect("plan created_at"); + let expires_at = plan["expires_at"].as_i64().expect("plan expires_at"); + assert_eq!( + expires_at - created_at, + 300_000_000, + "a protected preview stays valid for five minutes" + ); + let rendered = serde_json::to_string(payload).expect("preview json"); + for secret in hidden { + assert!( + !rendered.contains(secret), + "preview leaked {secret}: {rendered}" + ); + } +} + +fn assert_problem( + payload: &Value, + kind: &str, + code: &str, + message: &str, + retry: &str, + legal_actions: Value, +) { + assert_eq!(payload["problem"]["kind"], kind, "{payload}"); + assert_eq!(payload["problem"]["code"], code, "{payload}"); + assert_eq!(payload["problem"]["message"], message, "{payload}"); + assert_eq!(payload["problem"]["diagnostic"]["code"], code, "{payload}"); + assert_eq!( + payload["problem"]["diagnostic"]["message"], message, + "{payload}" + ); + assert_eq!(payload["problem"]["retry"], retry, "{payload}"); + assert_eq!( + payload["problem"]["legal_actions"], legal_actions, + "{payload}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn protected_preview_redacts_the_change_and_refuses_stale_or_invalid_input() { + let isolation = TempDir::new().expect("journey isolation"); + let project = isolation.path().join("project"); + initialize_project(&project); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("production composition"); + let graph = harness.server(&project).expect("project server").cg().await; + let project_id = graph + .configuration_runtime() + .configuration_target() + .project_id + .clone(); + let current = graph + .configuration_runtime() + .client() + .current() + .await + .expect("current configuration"); + let revision = current.revision_id().clone(); + let before_digest: ManifestDigest = current.snapshot().effective_behavior_digest.clone(); + drop(graph); + + let access_rule = deny_cursor_work(project_id.clone()); + let access_digest = access_rule + .compute_digest() + .expect("access rule digest") + .as_str() + .to_owned(); + let (refused, accepted) = call_preview( + &harness, + &project, + preview_arguments(&access_rule, &revision), + ) + .await; + assert!(!refused, "access-rule preview was refused: {accepted}"); + assert_redacted_plan( + &accepted, + revision.as_str(), + "scope.access_rules.v1", + "access_rule_upsert", + before_digest.as_str(), + &access_digest, + &[ACCESS_RULE_ID, DENIED_CAPABILITY], + ); + + let unbind = ProtectedChange::UnbindSource { + binding_id: SourceBindingId::new(ABSENT_BINDING_ID).expect("binding identity"), + }; + let unbind_digest = unbind + .compute_digest() + .expect("unbind digest") + .as_str() + .to_owned(); + assert_ne!( + access_digest, unbind_digest, + "the two submitted changes must not share a digest" + ); + let (refused, unbound) = + call_preview(&harness, &project, preview_arguments(&unbind, &revision)).await; + assert!(!refused, "unbind preview was refused: {unbound}"); + assert_redacted_plan( + &unbound, + revision.as_str(), + "scope.source_bindings.v1", + "source_unbind", + before_digest.as_str(), + &unbind_digest, + &[ABSENT_BINDING_ID], + ); + + let mut stale = preview_arguments(&access_rule, &revision); + stale["expected_revision"] = json!(STALE_REVISION); + let (refused, conflict) = call_preview(&harness, &project, stale).await; + assert!(refused, "a stale revision must be a tool error: {conflict}"); + assert_problem( + &conflict, + "conflict", + "configuration.conflict", + "The configuration request conflicts with current state", + "after_revalidate", + json!(["refresh"]), + ); + + let mut invalid = preview_arguments(&access_rule, &revision); + invalid["change"]["value"]["capabilities"] = json!([]); + let (refused, rejected) = call_preview(&harness, &project, invalid).await; + assert!( + refused, + "an empty capability set must be a tool error: {rejected}" + ); + assert_problem( + &rejected, + "invalid_request", + "configuration.invalid_request", + "The configuration request is invalid: access rule capabilities must not be empty", + "never", + json!([]), + ); + + let graph = harness.server(&project).expect("project server").cg().await; + let unchanged = graph + .configuration_runtime() + .client() + .current() + .await + .expect("configuration after previews") + .revision_id() + .clone(); + drop(graph); + assert_eq!( + unchanged.as_str(), + revision.as_str(), + "protected preview must not commit a revision" + ); + + harness.shutdown().await; +}