-
Notifications
You must be signed in to change notification settings - Fork 6
test(daemon): prove the protected preview stays redacted #1815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"), | ||
|
Comment on lines
+204
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This constructs an AGENTS.md reference: AGENTS.md:L9-L12 Useful? React with 👍 / 👎. |
||
| }; | ||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The submitted access rule embeds
project_idin itsAuthorityRef, but the leak check only searches for the rule ID and denied capability. A response that accidentally exposed the target project identity anywhere outside the exactly comparedredacted_changesobject would still pass, despite theProtectedChangePlancontract explicitly forbidding target identities. Includeproject_id.as_str()among the hidden values so this journey actually covers the full sensitive input it sends.AGENTS.md reference: AGENTS.md:L9-L12
Useful? React with 👍 / 👎.