diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index 420621436..4508d6ec7 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -15,12 +15,14 @@ use serde::{Deserialize, Serialize}; use serde_json::{self, Value}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +#[cfg(test)] +use crate::tracker::privacy_classifier::DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER; use crate::{ config::InternalReviewMode, github, prelude::eyre, state::StateStore, - tracker::{IssueTracker, TrackerIssue, public_text}, + tracker::{IssueTracker, TrackerIssue, privacy_classifier::PublicProjectionPrivacyClassifier}, workflow::WorkflowDocument, }; @@ -117,6 +119,7 @@ pub(crate) struct TrackerToolBridge<'a> { workflow: &'a WorkflowDocument, review_context: Option, state_store: Option<&'a StateStore>, + public_projection_privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, pull_request_inspector: &'a dyn PullRequestInspector, local_repo_inspector: &'a dyn LocalRepoInspector, local_issue_state_name: RefCell, @@ -140,6 +143,7 @@ impl<'a> TrackerToolBridge<'a> { workflow, review_context: None, state_store: None, + public_projection_privacy_classifier: &DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, pull_request_inspector: &GH_PULL_REQUEST_INSPECTOR, local_repo_inspector: &LOCAL_GIT_REPO_INSPECTOR, local_issue_state_name: RefCell::new(issue.state.name.clone()), @@ -154,6 +158,7 @@ impl<'a> TrackerToolBridge<'a> { } } + #[cfg(test)] fn with_review_handoff_inspectors( tracker: &'a dyn IssueTracker, issue: &'a TrackerIssue, @@ -162,15 +167,38 @@ impl<'a> TrackerToolBridge<'a> { state_store: Option<&'a StateStore>, pull_request_inspector: &'a dyn PullRequestInspector, local_repo_inspector: &'a dyn LocalRepoInspector, + ) -> Self { + Self::with_review_handoff_options( + tracker, + issue, + workflow, + review_context, + TrackerToolBridgeOptions { + state_store, + public_projection_privacy_classifier: + &DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + pull_request_inspector, + local_repo_inspector, + }, + ) + } + + fn with_review_handoff_options( + tracker: &'a dyn IssueTracker, + issue: &'a TrackerIssue, + workflow: &'a WorkflowDocument, + review_context: ReviewHandoffContext, + options: TrackerToolBridgeOptions<'a>, ) -> Self { Self { tracker, issue, workflow, review_context: Some(review_context), - state_store, - pull_request_inspector, - local_repo_inspector, + state_store: options.state_store, + public_projection_privacy_classifier: options.public_projection_privacy_classifier, + pull_request_inspector: options.pull_request_inspector, + local_repo_inspector: options.local_repo_inspector, local_issue_state_name: RefCell::new(issue.state.name.clone()), local_opt_out_requested: RefCell::new( issue.has_label(workflow.frontmatter().tracker().opt_out_label()), @@ -248,6 +276,7 @@ impl<'a> TrackerToolBridge<'a> { ) } + #[cfg(test)] pub(crate) fn with_run_context_and_state_store( tracker: &'a dyn IssueTracker, issue: &'a TrackerIssue, @@ -255,14 +284,63 @@ impl<'a> TrackerToolBridge<'a> { review_context: ReviewHandoffContext, state_store: &'a StateStore, ) -> Self { - Self::with_review_handoff_inspectors( + Self::with_review_handoff_options( tracker, issue, workflow, review_context, - Some(state_store), - &GH_PULL_REQUEST_INSPECTOR, - &LOCAL_GIT_REPO_INSPECTOR, + TrackerToolBridgeOptions { + state_store: Some(state_store), + public_projection_privacy_classifier: + &DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER, + pull_request_inspector: &GH_PULL_REQUEST_INSPECTOR, + local_repo_inspector: &LOCAL_GIT_REPO_INSPECTOR, + }, + ) + } + + pub(crate) fn with_run_context_state_store_and_privacy_classifier( + tracker: &'a dyn IssueTracker, + issue: &'a TrackerIssue, + workflow: &'a WorkflowDocument, + review_context: ReviewHandoffContext, + state_store: &'a StateStore, + public_projection_privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, + ) -> Self { + Self::with_review_handoff_options( + tracker, + issue, + workflow, + review_context, + TrackerToolBridgeOptions { + state_store: Some(state_store), + public_projection_privacy_classifier, + pull_request_inspector: &GH_PULL_REQUEST_INSPECTOR, + local_repo_inspector: &LOCAL_GIT_REPO_INSPECTOR, + }, + ) + } + + #[cfg(test)] + pub(crate) fn with_review_handoff_classifier_for_test( + tracker: &'a dyn IssueTracker, + issue: &'a TrackerIssue, + workflow: &'a WorkflowDocument, + review_context: ReviewHandoffContext, + public_projection_privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, + local_repo_inspector: &'a dyn LocalRepoInspector, + ) -> Self { + Self::with_review_handoff_options( + tracker, + issue, + workflow, + review_context, + TrackerToolBridgeOptions { + state_store: Some(Self::leaked_test_state_store()), + public_projection_privacy_classifier, + pull_request_inspector: &GH_PULL_REQUEST_INSPECTOR, + local_repo_inspector, + }, ) } @@ -496,6 +574,13 @@ impl Display for ReviewPolicyStopRequested { impl Error for ReviewPolicyStopRequested {} +struct TrackerToolBridgeOptions<'a> { + state_store: Option<&'a StateStore>, + public_projection_privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, + pull_request_inspector: &'a dyn PullRequestInspector, + local_repo_inspector: &'a dyn LocalRepoInspector, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct PendingReviewAction { pr_url: String, @@ -1083,10 +1168,6 @@ fn parse_github_remote_with_authority(remote_url: &str) -> std::result::Result<& Ok(path) } -fn validate_public_comment_body(body: &str) -> Result<(), String> { - public_text::validate_public_comment_body(body) -} - fn normalize_summary(summary: &str) -> String { summary.split_whitespace().collect::>().join(" ") } diff --git a/apps/decodex/src/agent/tracker_tool_bridge/review.rs b/apps/decodex/src/agent/tracker_tool_bridge/review.rs index 3c8e189ba..481220c6f 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/review.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/review.rs @@ -426,10 +426,11 @@ impl<'a> TrackerToolBridge<'a> { "review_handoff", &pending_review_handoff.summary, ); - - tracker_tool_bridge::validate_public_comment_body(&completion_comment) - .map_err(|error| eyre::eyre!(error))?; - + let projection = tracker::prepare_linear_execution_event_comment( + &completion_comment, + &handoff_record, + self.public_projection_privacy_classifier, + )?; let success_state = self.workflow.frontmatter().tracker().success_state(); let success_state_id = self.issue.state_id_for_name(success_state).ok_or_else(|| { eyre::eyre!( @@ -461,11 +462,10 @@ impl<'a> TrackerToolBridge<'a> { None, ); - if let Err(error) = tracker::create_linear_execution_event_comment( + if let Err(error) = tracker::create_prepared_linear_execution_event_comment( self.tracker, &self.issue.id, - &completion_comment, - &handoff_record, + &projection, ) { return Err(Report::new(ReviewHandoffWritebackFailed { issue_identifier: self.issue.identifier.clone(), @@ -476,7 +476,7 @@ impl<'a> TrackerToolBridge<'a> { })); } - self.persist_linear_execution_event(&handoff_record)?; + self.persist_linear_execution_event(&projection.record)?; self.persist_review_handoff_marker(review_context, &handoff_marker)?; self.persist_review_orchestration_marker(review_context, &orchestration_marker)?; @@ -544,10 +544,11 @@ impl<'a> TrackerToolBridge<'a> { pull_request.head_ref_name.clone(), pull_request.head_ref_oid.clone(), ); - - tracker_tool_bridge::validate_public_comment_body(&completion_comment) - .map_err(|error| eyre::eyre!(error))?; - + let projection = tracker::prepare_linear_execution_event_comment( + &completion_comment, + &handoff_record, + self.public_projection_privacy_classifier, + )?; let state_store = self.state_store.ok_or_else(|| { eyre::eyre!( "Runtime state store is required to read review orchestration for issue `{}`.", @@ -574,14 +575,13 @@ impl<'a> TrackerToolBridge<'a> { if marker.external_round_count() >= 4 { 0 } else { marker.external_round_count() } }); - tracker::create_linear_execution_event_comment( + tracker::create_prepared_linear_execution_event_comment( self.tracker, &self.issue.id, - &completion_comment, - &handoff_record, + &projection, )?; - self.persist_linear_execution_event(&handoff_record)?; + self.persist_linear_execution_event(&projection.record)?; self.persist_review_handoff_marker(review_context, &review_handoff)?; self.persist_review_orchestration_marker( review_context, @@ -713,17 +713,19 @@ impl<'a> TrackerToolBridge<'a> { review_context.worktree_path, summary, ); + let projection = tracker::prepare_linear_execution_event_comment( + &closeout_comment, + &closeout_record, + self.public_projection_privacy_classifier, + )?; - tracker_tool_bridge::validate_public_comment_body(&closeout_comment) - .map_err(|error| eyre::eyre!(error))?; - tracker::create_linear_execution_event_comment( + tracker::create_prepared_linear_execution_event_comment( self.tracker, &self.issue.id, - &closeout_comment, - &closeout_record, + &projection, )?; - self.persist_linear_execution_event(&closeout_record)?; + self.persist_linear_execution_event(&projection.record)?; Ok(()) } diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs index b9b438e43..9a30b99d8 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs @@ -28,6 +28,9 @@ use crate::{ state::{self, ReviewHandoffMarker, ReviewOrchestrationMarker, StateStore}, tracker::{ IssueTracker, TrackerComment, TrackerIssue, TrackerLabel, TrackerState, TrackerTeam, + privacy_classifier::{ + PublicProjectionPrivacyClassification, PublicProjectionPrivacyClassifier, + }, records, }, workflow::WorkflowDocument, diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs index b30e6ef57..2a3a4dff2 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs @@ -1,3 +1,25 @@ +struct FakeProjectionClassifier { + verdict: PublicProjectionPrivacyClassification, + seen_text: RefCell>, +} +impl FakeProjectionClassifier { + fn new(verdict: PublicProjectionPrivacyClassification) -> Self { + Self { verdict, seen_text: RefCell::new(Vec::new()) } + } +} + +impl PublicProjectionPrivacyClassifier for FakeProjectionClassifier { + fn classify_public_projection_text( + &self, + field_name: &str, + text: &str, + ) -> PublicProjectionPrivacyClassification { + self.seen_text.borrow_mut().push(format!("{field_name}:{text}")); + + self.verdict.clone() + } +} + #[test] fn progress_checkpoint_preserves_private_payload_and_publishes_projection() { let tracker = FakeTracker::new(); @@ -81,6 +103,150 @@ fn progress_checkpoint_preserves_private_payload_and_publishes_projection() { ); } +#[test] +fn progress_checkpoint_classifier_allows_public_projection() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let temp_dir = TempDir::new().expect("tempdir should create"); + let classifier = + FakeProjectionClassifier::new(PublicProjectionPrivacyClassification::Allow); + let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); + let bridge = TrackerToolBridge::with_review_handoff_classifier_for_test( + &tracker, + &issue, + &workflow, + sample_review_context_in(temp_dir.path()), + &classifier, + &local_repo_inspector, + ); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "implementing", + "focus": "Private focus stays local.", + "next_action": "Continue implementation.", + "blockers": [], + "evidence": ["Private evidence stays local."], + "head_sha": sample_local_repo().head_oid + }), + ); + + assert!(response.success); + + let record = records::parse_linear_execution_event_record(&tracker.comments.borrow()[0]) + .expect("progress checkpoint should be a Linear execution event"); + + assert_eq!(record.summary.as_deref(), Some("Execution phase: implementing.")); + assert!( + classifier + .seen_text + .borrow() + .iter() + .all(|text| !text.contains("Private focus") && !text.contains("Private evidence")) + ); +} + +#[test] +fn progress_checkpoint_suspicious_classifier_replaces_public_summary() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let temp_dir = TempDir::new().expect("tempdir should create"); + let classifier = + FakeProjectionClassifier::new(PublicProjectionPrivacyClassification::Suspicious { + reason: String::from("fake suspicious projection"), + }); + let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); + let bridge = TrackerToolBridge::with_review_handoff_classifier_for_test( + &tracker, + &issue, + &workflow, + sample_review_context_in(temp_dir.path()), + &classifier, + &local_repo_inspector, + ); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "implementing", + "focus": "Private focus stays local.", + "next_action": "Continue implementation.", + "blockers": [], + "evidence": ["Private evidence stays local."], + "head_sha": sample_local_repo().head_oid + }), + ); + + assert!(response.success); + + let record = records::parse_linear_execution_event_record(&tracker.comments.borrow()[0]) + .expect("progress checkpoint should be a Linear execution event"); + + assert_eq!( + record.summary.as_deref(), + Some(records::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_SUMMARY) + ); +} + +#[test] +fn progress_checkpoint_unavailable_classifier_preserves_private_event() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let temp_dir = TempDir::new().expect("tempdir should create"); + let classifier = + FakeProjectionClassifier::new(PublicProjectionPrivacyClassification::Unavailable { + reason: String::from("fake unavailable classifier"), + }); + let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); + let bridge = TrackerToolBridge::with_review_handoff_classifier_for_test( + &tracker, + &issue, + &workflow, + sample_review_context_in(temp_dir.path()), + &classifier, + &local_repo_inspector, + ); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "verifying", + "focus": "Private verification focus stays local.", + "next_action": "Continue verification.", + "blockers": [], + "evidence": ["Private verification evidence stays local."], + "head_sha": sample_local_repo().head_oid + }), + ); + + assert!(response.success); + + let record = records::parse_linear_execution_event_record(&tracker.comments.borrow()[0]) + .expect("progress checkpoint should be a Linear execution event"); + + assert_eq!( + record.summary.as_deref(), + Some(records::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_SUMMARY) + ); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!( + private_events[0].payload()["focus"], + serde_json::json!("Private verification focus stays local.") + ); + assert_eq!( + private_events[0].payload()["evidence"], + serde_json::json!(["Private verification evidence stays local."]) + ); +} + #[test] fn blocked_progress_checkpoint_requires_concrete_blocker() { let tracker = FakeTracker::new(); diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index b1fedc8eb..298079831 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -543,15 +543,26 @@ impl<'a> TrackerToolBridge<'a> { state_store: &StateStore, public_projection: &LinearExecutionEventRecord, ) -> Result { - if self.progress_checkpoint_projection_cached(state_store, public_projection)? { + let projection = tracker::prepare_linear_execution_event_comment( + "", + public_projection, + self.public_projection_privacy_classifier, + ) + .map_err(|error| { + format!( + "Failed to prepare the public progress projection for issue `{}`: {error}", + self.issue.identifier + ) + })?; + + if self.progress_checkpoint_projection_cached(state_store, &projection.record)? { return Ok(false); } - let comment_created = match tracker::create_linear_execution_event_comment( + let comment_created = match tracker::create_prepared_linear_execution_event_comment( self.tracker, &self.issue.id, - "", - public_projection, + &projection, ) { Ok(comment_created) => comment_created, Err(error) => @@ -561,7 +572,7 @@ impl<'a> TrackerToolBridge<'a> { )), }; - state_store.record_linear_execution_event(public_projection).map_err(|error| { + state_store.record_linear_execution_event(&projection.record).map_err(|error| { format!( "Failed to persist the public progress projection cache for issue `{}`: {error}", self.issue.identifier @@ -700,22 +711,22 @@ impl<'a> TrackerToolBridge<'a> { }; let record = self.manual_attention_execution_event(review_context, &comment); let body = format_manual_attention_comment(review_context, &comment); + let projection = match tracker::prepare_linear_execution_event_comment( + &body, + &record, + self.public_projection_privacy_classifier, + ) { + Ok(projection) => projection, + Err(error) => return DynamicToolCallResponse::failure(error.to_string()), + }; - if let Err(error) = records::validate_linear_execution_event_record(&record) { - return DynamicToolCallResponse::failure(error); - } - if let Err(error) = tracker_tool_bridge::validate_public_comment_body(&body) { - return DynamicToolCallResponse::failure(error); - } - - match tracker::create_linear_execution_event_comment( + match tracker::create_prepared_linear_execution_event_comment( self.tracker, &self.issue.id, - &body, - &record, + &projection, ) { Ok(created) => { - if let Err(error) = state_store.record_linear_execution_event(&record) { + if let Err(error) = state_store.record_linear_execution_event(&projection.record) { return DynamicToolCallResponse::failure(format!( "Failed to persist the public manual-attention summary for issue `{}`: {error}", self.issue.identifier diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index 4410fa303..b0567df34 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -10,6 +10,7 @@ use std::{ process::Command, }; +use reqwest::Url; use serde::Deserialize; use crate::prelude::{Result, eyre}; @@ -27,6 +28,7 @@ pub struct ServiceConfig { tracker: ProjectTrackerConfig, github: ProjectGitHubConfig, codex: ProjectCodexConfig, + privacy_classifier: ProjectPrivacyClassifierConfig, } impl ServiceConfig { /// Parse service configuration from TOML text. @@ -87,6 +89,11 @@ impl ServiceConfig { &self.codex } + /// Optional local classifier for Linear public projection text. + pub fn privacy_classifier(&self) -> &ProjectPrivacyClassifierConfig { + &self.privacy_classifier + } + fn from_document(document: ServiceConfigDocument, config_dir: &Path) -> Result { document.validate()?; @@ -102,6 +109,7 @@ impl ServiceConfig { tracker: document.tracker, github: document.github, codex: document.codex.resolve_paths(config_dir)?, + privacy_classifier: document.privacy_classifier, }) } } @@ -209,6 +217,47 @@ impl Default for ProjectCodexConfig { } } +/// Optional local-only classifier for public Linear projection text. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectPrivacyClassifierConfig { + endpoint: Option, + #[serde(default = "default_privacy_classifier_timeout_ms")] + timeout_ms: u64, +} +impl ProjectPrivacyClassifierConfig { + /// Loopback HTTP endpoint for an operator-managed local classifier runtime. + pub fn endpoint(&self) -> Option<&str> { + self.endpoint.as_deref() + } + + /// Per-field local classifier request timeout. + pub fn timeout_ms(&self) -> u64 { + self.timeout_ms + } + + fn validate(&self) -> Result<()> { + if self.timeout_ms == 0 { + eyre::bail!("`privacy_classifier.timeout_ms` must be greater than zero."); + } + if self.timeout_ms > 30_000 { + eyre::bail!("`privacy_classifier.timeout_ms` must be 30000 or less."); + } + + if let Some(endpoint) = self.endpoint.as_deref() { + validate_local_privacy_classifier_endpoint(endpoint)?; + } + + Ok(()) + } +} + +impl Default for ProjectPrivacyClassifierConfig { + fn default() -> Self { + Self { endpoint: None, timeout_ms: default_privacy_classifier_timeout_ms() } + } +} + /// Optional JSONL ChatGPT accounts for Codex app-server runs. #[derive(Clone, Debug, Eq, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] @@ -297,6 +346,8 @@ struct ServiceConfigDocument { #[serde(default)] codex: ProjectCodexConfig, #[serde(default)] + privacy_classifier: ProjectPrivacyClassifierConfig, + #[serde(default)] paths: ProjectPathsConfig, } impl ServiceConfigDocument { @@ -306,6 +357,7 @@ impl ServiceConfigDocument { self.tracker.validate()?; self.github.validate()?; self.codex.validate()?; + self.privacy_classifier.validate()?; self.paths.validate()?; Ok(()) @@ -380,6 +432,32 @@ const fn default_internal_review_mode() -> InternalReviewMode { InternalReviewMode::Loop } +const fn default_privacy_classifier_timeout_ms() -> u64 { + 1_000 +} + +fn validate_local_privacy_classifier_endpoint(endpoint: &str) -> Result<()> { + let url = Url::parse(endpoint) + .map_err(|error| eyre::eyre!("`privacy_classifier.endpoint` must be a URL: {error}"))?; + + if url.scheme() != "http" { + eyre::bail!("`privacy_classifier.endpoint` must use `http` on a loopback host."); + } + if !url.username().is_empty() || url.password().is_some() { + eyre::bail!("`privacy_classifier.endpoint` must not contain credentials."); + } + + let Some(host) = url.host_str() else { + eyre::bail!("`privacy_classifier.endpoint` must include a loopback host."); + }; + + if !matches!(host, "localhost" | "127.0.0.1" | "::1") { + eyre::bail!("`privacy_classifier.endpoint` must point to a loopback host, not `{host}`."); + } + + Ok(()) +} + fn shared_repo_root_for_checkout( cwd: &Path, worktree_root: Option<&Path>, @@ -1072,6 +1150,81 @@ mod tests { } } + #[test] + fn project_privacy_classifier_defaults_to_disabled() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + "#, + ); + let config = + ServiceConfig::from_path(&config_path).expect("service config should load from disk"); + + assert_eq!(config.privacy_classifier().endpoint(), None); + assert_eq!(config.privacy_classifier().timeout_ms(), 1_000); + } + + #[test] + fn parses_loopback_privacy_classifier_endpoint() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + + [privacy_classifier] + endpoint = "http://127.0.0.1:9123/classify" + timeout_ms = 250 + "#, + ); + let config = + ServiceConfig::from_path(&config_path).expect("service config should load from disk"); + + assert_eq!(config.privacy_classifier().endpoint(), Some("http://127.0.0.1:9123/classify")); + assert_eq!(config.privacy_classifier().timeout_ms(), 250); + } + + #[test] + fn rejects_remote_privacy_classifier_endpoint() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + + [privacy_classifier] + endpoint = "https://example.com/classify" + "#, + ); + let error = ServiceConfig::from_path(&config_path) + .expect_err("remote classifier endpoints should be rejected"); + + assert!( + error.to_string().contains("loopback"), + "error should explain local-only classifier routing: {error:?}" + ); + } + #[test] fn parses_codex_accounts_settings() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/apps/decodex/src/orchestrator/daemon.rs b/apps/decodex/src/orchestrator/daemon.rs index 25091a38f..a9401aa8c 100644 --- a/apps/decodex/src/orchestrator/daemon.rs +++ b/apps/decodex/src/orchestrator/daemon.rs @@ -1248,11 +1248,13 @@ where child.run_id, issue.identifier )) }; + let privacy_classifier = configured_public_projection_privacy_classifier(context.project)?; let outcome = apply_terminal_failure_writeback( context.tracker, TerminalFailureWritebackRuntime { service_id: context.project.service_id(), state_store: Some(context.state_store), + privacy_classifier: &privacy_classifier, }, context.workflow, &issue_run, diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index 7bb67503b..b3208698d 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -3,6 +3,8 @@ use agent::CodexAccountPool; use agent::CodexAccountProvider; use agent::AppServerThreadArchiveRequest; +use crate::tracker::privacy_classifier::PublicProjectionPrivacyClassifier; + struct AgentGitCredentialEnvironment { process_env: AppServerProcessEnv, askpass_path: PathBuf, @@ -35,9 +37,16 @@ struct TerminalFailureLifecycle<'a> { manual_attention_requested: bool, } +struct RunStartedLifecycleFields<'a> { + worktree_path: &'a str, + commit_sha: &'a str, + privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, +} + struct TerminalFailureWritebackRuntime<'a> { service_id: &'a str, state_store: Option<&'a StateStore>, + privacy_classifier: &'a dyn PublicProjectionPrivacyClassifier, } struct CompletedAppServerRun<'a, T> @@ -196,6 +205,14 @@ fn sanitize_run_id_for_path(run_id: &str) -> String { if sanitized.is_empty() { String::from("run") } else { sanitized } } +fn configured_public_projection_privacy_classifier( + project: &ServiceConfig, +) -> Result { + tracker::privacy_classifier::ConfiguredPublicProjectionPrivacyClassifier::from_config( + project.privacy_classifier(), + ) +} + fn lifecycle_event_identity<'a>( project: &'a ServiceConfig, issue_run: &'a IssueRunPlan, @@ -214,18 +231,23 @@ fn write_lifecycle_event( state_store: &StateStore, issue_id: &str, record: &records::LinearExecutionEventRecord, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, ) -> Result<()> where T: IssueTracker + ?Sized, { let body = format!("Decodex execution event: {}", record.event_type); + let projection = + tracker::prepare_linear_execution_event_comment(&body, record, privacy_classifier)?; - if state_store.record_linear_execution_event(record)? - && let Err(error) = tracker::create_linear_execution_event_comment_without_remote_scan( - tracker, issue_id, &body, record, + if state_store.record_linear_execution_event(&projection.record)? + && let Err(error) = tracker::create_prepared_linear_execution_event_comment_without_remote_scan( + tracker, + issue_id, + &projection, ) { - state_store.forget_linear_execution_event(&record.idempotency_key)?; + state_store.forget_linear_execution_event(&projection.record.idempotency_key)?; return Err(error); } @@ -244,6 +266,7 @@ where T: IssueTracker + ?Sized, { let worktree_path = relative_worktree_path(project, &issue_run.worktree); + let privacy_classifier = configured_public_projection_privacy_classifier(project)?; let commit_sha = worktree_head_oid(&issue_run.worktree.path)?.ok_or_else(|| { eyre::eyre!( "Prepared worktree `{}` for issue `{}` did not expose a HEAD commit.", @@ -258,8 +281,11 @@ where workflow, state_store, issue_run, - &worktree_path, - &commit_sha, + RunStartedLifecycleFields { + worktree_path: &worktree_path, + commit_sha: &commit_sha, + privacy_classifier: &privacy_classifier, + }, ) } @@ -269,8 +295,7 @@ fn write_run_started_lifecycle_event( workflow: &WorkflowDocument, state_store: &StateStore, issue_run: &IssueRunPlan, - worktree_path: &str, - commit_sha: &str, + fields: RunStartedLifecycleFields<'_>, ) -> Result<()> where T: IssueTracker + ?Sized, @@ -279,7 +304,7 @@ where let anchor = records::stable_event_anchor(&[ issue_run.dispatch_mode.as_str(), &issue_run.worktree.branch_name, - commit_sha, + fields.commit_sha, transport, ]); let mut record = records::LinearExecutionEventRecord::new( @@ -290,15 +315,21 @@ where ); record.branch = Some(issue_run.worktree.branch_name.clone()); - record.worktree_path = Some(worktree_path.to_owned()); - record.commit_sha = Some(commit_sha.to_owned()); + record.worktree_path = Some(fields.worktree_path.to_owned()); + record.commit_sha = Some(fields.commit_sha.to_owned()); record.transport = Some(transport.to_owned()); record.summary = Some(format!( "Decodex started a {} run for this issue.", issue_run.dispatch_mode.as_str() )); - write_lifecycle_event(tracker, state_store, &issue_run.issue.id, &record) + write_lifecycle_event( + tracker, + state_store, + &issue_run.issue.id, + &record, + fields.privacy_classifier, + ) } fn terminal_failure_lifecycle_event( @@ -360,6 +391,7 @@ where T: IssueTracker + ?Sized, { let worktree_path = relative_worktree_path(project, &issue_run.worktree); + let privacy_classifier = configured_public_projection_privacy_classifier(project)?; let anchor = records::stable_event_anchor(&[ &issue_run.worktree.branch_name, commit_sha.unwrap_or_default(), @@ -379,7 +411,13 @@ where record.pr_url = pr_url.map(ToOwned::to_owned); record.commit_sha = commit_sha.map(ToOwned::to_owned); - write_lifecycle_event(tracker, state_store, &issue_run.issue.id, &record) + write_lifecycle_event( + tracker, + state_store, + &issue_run.issue.id, + &record, + &privacy_classifier, + ) } fn execute_issue_run_inner( @@ -393,13 +431,15 @@ where T: IssueTracker, { let transport = workflow.frontmatter().agent().transport().to_owned(); + let privacy_classifier = configured_public_projection_privacy_classifier(project)?; let review_context = build_review_run_context(project, state_store, issue_run)?; - let tracker_tool_bridge = TrackerToolBridge::with_run_context_and_state_store( + let tracker_tool_bridge = TrackerToolBridge::with_run_context_state_store_and_privacy_classifier( tracker, &issue_run.issue, workflow, review_context.clone(), state_store, + &privacy_classifier, ); if let Some(summary) = maybe_execute_deterministic_closeout( @@ -1075,11 +1115,13 @@ where &worktree_path, ); let terminal_error = retained_partial_progress.as_ref().unwrap_or(error); + let privacy_classifier = configured_public_projection_privacy_classifier(project)?; let outcome = apply_terminal_failure_writeback( tracker, TerminalFailureWritebackRuntime { service_id: project.service_id(), state_store: Some(state_store), + privacy_classifier: &privacy_classifier, }, workflow, issue_run, @@ -1274,26 +1316,29 @@ where manual_attention_requested, }, ); + let projection = tracker::prepare_linear_execution_event_comment( + &comment, + &event, + runtime.privacy_classifier, + )?; if let Some(state_store) = runtime.state_store { - if state_store.record_linear_execution_event(&event)? - && let Err(error) = tracker::create_linear_execution_event_comment_without_remote_scan( + if state_store.record_linear_execution_event(&projection.record)? + && let Err(error) = tracker::create_prepared_linear_execution_event_comment_without_remote_scan( tracker, &issue_run.issue.id, - &comment, - &event, + &projection, ) { - state_store.forget_linear_execution_event(&event.idempotency_key)?; + state_store.forget_linear_execution_event(&projection.record.idempotency_key)?; return Err(error); } } else { - tracker::create_linear_execution_event_comment( + tracker::create_prepared_linear_execution_event_comment( tracker, &issue_run.issue.id, - &comment, - &event, + &projection, )?; } diff --git a/apps/decodex/src/orchestrator/prompting.rs b/apps/decodex/src/orchestrator/prompting.rs index caf83ff02..fba85ec36 100644 --- a/apps/decodex/src/orchestrator/prompting.rs +++ b/apps/decodex/src/orchestrator/prompting.rs @@ -1,5 +1,5 @@ pub(crate) const TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION: &str = - "Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- `issue_comment` accepts only allowlisted public comment kinds. For manual attention, call it with `kind: \"manual_attention\"` and structured public fields; do not send arbitrary comment bodies.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths."; + "Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- `issue_comment` accepts only allowlisted public comment kinds. For manual attention, call it with `kind: \"manual_attention\"` and structured public fields; do not send arbitrary comment bodies.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths.\n- Decodex may apply a local-only secondary privacy classifier to rendered public projections, but that classifier is not the privacy boundary; keep private evidence out of public fields before tool calls."; const PROMPT_ONLY_INTERNAL_REVIEW_INSTRUCTION: &str = "Review your work repeatedly and fix any logic bugs until no new issues are found."; diff --git a/apps/decodex/src/orchestrator/run_cycle.rs b/apps/decodex/src/orchestrator/run_cycle.rs index 4cf184cef..d39243ac7 100644 --- a/apps/decodex/src/orchestrator/run_cycle.rs +++ b/apps/decodex/src/orchestrator/run_cycle.rs @@ -1113,9 +1113,14 @@ where }; let worktree_path = relative_worktree_path_for_path(project, synthetic_issue_run.worktree.path.as_path()); + let privacy_classifier = configured_public_projection_privacy_classifier(project)?; let _ = apply_terminal_failure_writeback( tracker, - TerminalFailureWritebackRuntime { service_id: project.service_id(), state_store: None }, + TerminalFailureWritebackRuntime { + service_id: project.service_id(), + state_store: None, + privacy_classifier: &privacy_classifier, + }, workflow, &synthetic_issue_run, &worktree_path, diff --git a/apps/decodex/src/recovery.rs b/apps/decodex/src/recovery.rs index ea0d16fea..f6d33c023 100644 --- a/apps/decodex/src/recovery.rs +++ b/apps/decodex/src/recovery.rs @@ -23,6 +23,7 @@ use crate::{ tracker::{ self, IssueTracker, TrackerIssue, linear::LinearClient, + privacy_classifier::ConfiguredPublicProjectionPrivacyClassifier, records::{self, LinearExecutionEventIdentity, LinearExecutionEventRecord}, }, workflow::WorkflowDocument, @@ -1067,12 +1068,16 @@ fn write_rebind_audit( validation.issue.identifier, landing_url(&validation.landing_state) ); + let privacy_classifier = ConfiguredPublicProjectionPrivacyClassifier::from_config( + context.config.privacy_classifier(), + )?; + let projection = + tracker::prepare_linear_execution_event_comment(&body, event, &privacy_classifier)?; - tracker::create_linear_execution_event_comment( + tracker::create_prepared_linear_execution_event_comment( &context.tracker, &validation.issue.id, - &body, - event, + &projection, )?; Ok(()) diff --git a/apps/decodex/src/tracker.rs b/apps/decodex/src/tracker.rs index ef55fc502..d07764b2a 100644 --- a/apps/decodex/src/tracker.rs +++ b/apps/decodex/src/tracker.rs @@ -1,4 +1,5 @@ pub(crate) mod linear; +pub(crate) mod privacy_classifier; pub(crate) mod public_text; pub(crate) mod records; @@ -7,7 +8,8 @@ use std::slice; use color_eyre::Report; use crate::prelude::{Result, eyre}; -use records::LinearExecutionEventRecord; +use privacy_classifier::PublicProjectionPrivacyClassifier; +use records::{LinearExecutionEventPublicProjection, LinearExecutionEventRecord}; pub(crate) trait IssueTracker { fn list_issues_with_label(&self, label_name: &str) -> Result>; @@ -220,30 +222,45 @@ where Ok(()) } -pub(crate) fn create_linear_execution_event_comment( - tracker: &T, - issue_id: &str, +pub(crate) fn prepare_linear_execution_event_comment( body: &str, record: &LinearExecutionEventRecord, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, +) -> Result { + let projection = + records::linear_execution_event_public_projection(body, record, privacy_classifier); + + records::validate_linear_execution_event_record(&projection.record) + .map_err(|error| eyre::eyre!(error))?; + public_text::validate_public_comment_body(&projection.body) + .map_err(|error| eyre::eyre!(error))?; + + Ok(projection) +} + +pub(crate) fn create_prepared_linear_execution_event_comment( + tracker: &T, + issue_id: &str, + projection: &LinearExecutionEventPublicProjection, ) -> Result where T: IssueTracker + ?Sized, { - records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; - public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?; - let comments = tracker.list_comments(issue_id)?; if records::has_linear_execution_event_record( &comments, - &record.service_id, - &record.issue_id, - &record.idempotency_key, + &projection.record.service_id, + &projection.record.issue_id, + &projection.record.idempotency_key, ) { return Ok(false); } - let comment_body = records::append_structured_comment_record(body, record)?; + log_privacy_classifier_withheld_projection(projection); + + let comment_body = + records::append_structured_comment_record(&projection.body, &projection.record)?; tracker.create_comment(issue_id, &comment_body)?; @@ -259,19 +276,29 @@ where tracker.create_comment(issue_id, body) } -pub(crate) fn create_linear_execution_event_comment_without_remote_scan( +pub(crate) fn create_prepared_linear_execution_event_comment_without_remote_scan( tracker: &T, issue_id: &str, - body: &str, - record: &LinearExecutionEventRecord, + projection: &LinearExecutionEventPublicProjection, ) -> Result<()> where T: IssueTracker + ?Sized, { - records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; - public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?; + log_privacy_classifier_withheld_projection(projection); - let comment_body = records::append_structured_comment_record(body, record)?; + let comment_body = + records::append_structured_comment_record(&projection.body, &projection.record)?; tracker.create_comment(issue_id, &comment_body) } + +fn log_privacy_classifier_withheld_projection(projection: &LinearExecutionEventPublicProjection) { + if projection.classifier_withheld_text { + tracing::warn!( + service_id = projection.record.service_id, + issue_id = projection.record.issue_id, + event_type = projection.record.event_type, + "Local privacy classifier withheld Linear public projection text." + ); + } +} diff --git a/apps/decodex/src/tracker/privacy_classifier.rs b/apps/decodex/src/tracker/privacy_classifier.rs new file mode 100644 index 000000000..6d1947af3 --- /dev/null +++ b/apps/decodex/src/tracker/privacy_classifier.rs @@ -0,0 +1,159 @@ +use std::time::Duration; + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; + +use crate::{config::ProjectPrivacyClassifierConfig, prelude::Result}; + +pub(crate) static DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER: + DisabledPublicProjectionPrivacyClassifier = DisabledPublicProjectionPrivacyClassifier; + +/// Local-only classifier boundary for text already selected for public Linear projection. +pub(crate) trait PublicProjectionPrivacyClassifier { + /// Classify one public projection field. + fn classify_public_projection_text( + &self, + field_name: &str, + text: &str, + ) -> PublicProjectionPrivacyClassification; +} + +/// Classification verdict for one public Linear projection text field. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PublicProjectionPrivacyClassification { + /// The text is safe to publish. + Allow, + /// The classifier identified possible private information. + Suspicious { reason: String }, + /// The configured classifier could not return a trustworthy answer. + Unavailable { reason: String }, +} + +/// Runtime-selected local classifier implementation. +pub(crate) enum ConfiguredPublicProjectionPrivacyClassifier { + /// No classifier is configured. + Disabled, + /// Loopback HTTP adapter for an operator-managed local classifier runtime. + LocalHttp(LocalHttpPublicProjectionPrivacyClassifier), +} +impl ConfiguredPublicProjectionPrivacyClassifier { + /// Build the runtime classifier selected by project config. + pub(crate) fn from_config(config: &ProjectPrivacyClassifierConfig) -> Result { + let Some(endpoint) = config.endpoint() else { + return Ok(Self::Disabled); + }; + + Ok(Self::LocalHttp(LocalHttpPublicProjectionPrivacyClassifier::new( + endpoint, + config.timeout_ms(), + )?)) + } +} + +impl PublicProjectionPrivacyClassifier for ConfiguredPublicProjectionPrivacyClassifier { + fn classify_public_projection_text( + &self, + field_name: &str, + text: &str, + ) -> PublicProjectionPrivacyClassification { + match self { + Self::Disabled => DISABLED_PUBLIC_PROJECTION_PRIVACY_CLASSIFIER + .classify_public_projection_text(field_name, text), + Self::LocalHttp(classifier) => + classifier.classify_public_projection_text(field_name, text), + } + } +} + +/// Default classifier used when no local runtime is configured. +pub(crate) struct DisabledPublicProjectionPrivacyClassifier; +impl PublicProjectionPrivacyClassifier for DisabledPublicProjectionPrivacyClassifier { + fn classify_public_projection_text( + &self, + _field_name: &str, + _text: &str, + ) -> PublicProjectionPrivacyClassification { + PublicProjectionPrivacyClassification::Allow + } +} + +/// Loopback HTTP adapter for an operator-managed local classifier runtime. +pub(crate) struct LocalHttpPublicProjectionPrivacyClassifier { + client: Client, + endpoint: String, +} +impl LocalHttpPublicProjectionPrivacyClassifier { + fn new(endpoint: &str, timeout_ms: u64) -> Result { + let client = Client::builder().timeout(Duration::from_millis(timeout_ms)).build()?; + + Ok(Self { client, endpoint: endpoint.to_owned() }) + } +} + +impl PublicProjectionPrivacyClassifier for LocalHttpPublicProjectionPrivacyClassifier { + fn classify_public_projection_text( + &self, + field_name: &str, + text: &str, + ) -> PublicProjectionPrivacyClassification { + let request = LocalClassifierRequest { field_name, text }; + let response = match self.client.post(&self.endpoint).json(&request).send() { + Ok(response) => response, + Err(error) => { + return PublicProjectionPrivacyClassification::Unavailable { + reason: format!("local classifier request failed: {error}"), + }; + }, + }; + + if !response.status().is_success() { + return PublicProjectionPrivacyClassification::Unavailable { + reason: format!("local classifier returned HTTP {}", response.status()), + }; + } + + let response = match response.json::() { + Ok(response) => response, + Err(error) => { + return PublicProjectionPrivacyClassification::Unavailable { + reason: format!("local classifier response was invalid: {error}"), + }; + }, + }; + + response.into_classification() + } +} + +#[derive(Serialize)] +struct LocalClassifierRequest<'a> { + field_name: &'a str, + text: &'a str, +} + +#[derive(Deserialize)] +struct LocalClassifierResponse { + verdict: String, + reason: Option, +} +impl LocalClassifierResponse { + fn into_classification(self) -> PublicProjectionPrivacyClassification { + let reason = || { + self.reason + .clone() + .filter(|reason| !reason.trim().is_empty()) + .unwrap_or_else(|| String::from("local classifier did not provide a reason")) + }; + + match self.verdict.trim().to_ascii_lowercase().as_str() { + "allow" => PublicProjectionPrivacyClassification::Allow, + "suspicious" | "block" | "blocked" => + PublicProjectionPrivacyClassification::Suspicious { reason: reason() }, + "unavailable" => + PublicProjectionPrivacyClassification::Unavailable { reason: reason() }, + other => PublicProjectionPrivacyClassification::Unavailable { + reason: format!("local classifier returned unsupported verdict `{other}`"), + }, + } + } +} diff --git a/apps/decodex/src/tracker/records.rs b/apps/decodex/src/tracker/records.rs index 4b563c477..72bc51b57 100644 --- a/apps/decodex/src/tracker/records.rs +++ b/apps/decodex/src/tracker/records.rs @@ -3,7 +3,13 @@ use std::path::{Component, Path}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Error; -use crate::tracker::{TrackerComment, public_text}; +use crate::tracker::{ + TrackerComment, + privacy_classifier::{ + PublicProjectionPrivacyClassification, PublicProjectionPrivacyClassifier, + }, + public_text, +}; #[cfg(test)] pub(crate) const REVIEW_HANDOFF_RECORD_TYPE: &str = "review-handoff-record/1"; @@ -11,6 +17,15 @@ pub(crate) const REVIEW_HANDOFF_RECORD_TYPE: &str = "review-handoff-record/1"; pub(crate) const CLOSEOUT_RECORD_TYPE: &str = "closeout-record/1"; pub(crate) const LINEAR_EXECUTION_EVENT_RECORD_TYPE: &str = "decodex.linear_execution_event"; pub(crate) const LINEAR_EXECUTION_EVENT_RECORD_VERSION: i64 = 1; +pub(crate) const PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_SUMMARY: &str = + "Public summary withheld by local privacy classifier."; + +const PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_ACTION: &str = + "Public action withheld by local privacy classifier; review private Decodex evidence."; +const PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL: &str = + "Public detail withheld by local privacy classifier."; +const PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_COMMENT_BODY: &str = + "Public comment details withheld by local privacy classifier."; #[cfg(test)] #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -155,6 +170,29 @@ pub(crate) struct LinearExecutionEventIdentity<'a> { pub(crate) attempt_number: i64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LinearExecutionEventPublicProjection { + pub(crate) body: String, + pub(crate) record: LinearExecutionEventRecord, + pub(crate) classifier_withheld_text: bool, +} + +pub(crate) fn linear_execution_event_public_projection( + body: &str, + record: &LinearExecutionEventRecord, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, +) -> LinearExecutionEventPublicProjection { + let (body, body_withheld) = classify_public_comment_body(body, privacy_classifier); + let (record, record_withheld) = + classify_linear_execution_event_record(record, privacy_classifier); + + LinearExecutionEventPublicProjection { + body, + record, + classifier_withheld_text: body_withheld || record_withheld, + } +} + /// Render the low-frequency public Linear projection for a private progress checkpoint. pub(crate) fn render_progress_checkpoint_public_projection( identity: LinearExecutionEventIdentity<'_>, @@ -286,6 +324,183 @@ fn validate_linear_execution_event_public_text( Ok(()) } +fn classify_public_comment_body( + body: &str, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, +) -> (String, bool) { + if body.trim().is_empty() { + return (String::new(), false); + } + if classifier_allows(privacy_classifier, "body", body) { + return (body.to_owned(), false); + } + + (String::from(PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_COMMENT_BODY), true) +} + +fn classify_linear_execution_event_record( + record: &LinearExecutionEventRecord, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, +) -> (LinearExecutionEventRecord, bool) { + let mut record = record.clone(); + let event_type = record.event_type.clone(); + let mut withheld = false; + + classify_optional_text_field( + &mut record.summary, + "summary", + event_requires_summary(&event_type), + PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_SUMMARY, + privacy_classifier, + &mut withheld, + ); + classify_optional_text_field( + &mut record.focus, + "focus", + false, + PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL, + privacy_classifier, + &mut withheld, + ); + classify_optional_text_field( + &mut record.next_action, + "next_action", + event_requires_next_action(&event_type), + PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_ACTION, + privacy_classifier, + &mut withheld, + ); + classify_optional_text_field( + &mut record.failed_command, + "failed_command", + false, + PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL, + privacy_classifier, + &mut withheld, + ); + classify_optional_text_field( + &mut record.raw_error, + "raw_error", + false, + PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL, + privacy_classifier, + &mut withheld, + ); + classify_optional_text_items( + &mut record.blockers, + "blockers", + event_requires_items(&event_type, "blockers"), + privacy_classifier, + &mut withheld, + ); + classify_optional_text_items( + &mut record.evidence, + "evidence", + event_requires_items(&event_type, "evidence"), + privacy_classifier, + &mut withheld, + ); + classify_optional_text_items( + &mut record.verification, + "verification", + false, + privacy_classifier, + &mut withheld, + ); + + (record, withheld) +} + +fn classify_optional_text_field( + value: &mut Option, + field_name: &str, + required: bool, + fallback: &str, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, + withheld: &mut bool, +) { + let Some(current) = value.as_deref() else { + return; + }; + + if classifier_allows(privacy_classifier, field_name, current) { + return; + } + + *withheld = true; + + if required { + *value = Some(fallback.to_owned()); + } else { + *value = None; + } +} + +fn classify_optional_text_items( + values: &mut Option>, + field_name: &str, + required: bool, + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, + withheld: &mut bool, +) { + let Some(current_values) = values.take() else { + return; + }; + let mut retained = Vec::new(); + + for value in current_values { + if classifier_allows(privacy_classifier, field_name, &value) { + retained.push(value); + } else { + *withheld = true; + } + } + + if retained.is_empty() { + *values = required.then(|| vec![String::from(PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL)]); + } else { + *values = Some(retained); + } +} + +fn classifier_allows( + privacy_classifier: &dyn PublicProjectionPrivacyClassifier, + field_name: &str, + value: &str, +) -> bool { + matches!( + privacy_classifier.classify_public_projection_text(field_name, value), + PublicProjectionPrivacyClassification::Allow + ) +} + +fn event_requires_summary(event_type: &str) -> bool { + matches!( + event_type, + "run_started" + | "intake" + | "progress_checkpoint" + | "review_handoff" + | "repair_handoff" + | "review_handoff_rebind" + | "landed" + | "closeout" + | "cleanup_complete" + ) +} + +fn event_requires_next_action(event_type: &str) -> bool { + matches!(event_type, "needs_attention" | "terminal_failure") +} + +fn event_requires_items(event_type: &str, field_name: &str) -> bool { + matches!( + (event_type, field_name), + ("needs_attention" | "terminal_failure" | "review_handoff_rebind", "evidence") + | ("needs_attention" | "terminal_failure", "blockers") + ) +} + fn parse_structured_comment(body: &str) -> Option where T: DeserializeOwned, @@ -513,7 +728,53 @@ fn has_drive_root_prefix(path: &str) -> bool { #[cfg(test)] mod tests { - use crate::tracker::records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}; + use crate::tracker::{ + privacy_classifier::{ + PublicProjectionPrivacyClassification, PublicProjectionPrivacyClassifier, + }, + records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}, + }; + + struct AllowingClassifier; + impl PublicProjectionPrivacyClassifier for AllowingClassifier { + fn classify_public_projection_text( + &self, + _field_name: &str, + _text: &str, + ) -> PublicProjectionPrivacyClassification { + PublicProjectionPrivacyClassification::Allow + } + } + + struct SuspiciousWordClassifier; + impl PublicProjectionPrivacyClassifier for SuspiciousWordClassifier { + fn classify_public_projection_text( + &self, + _field_name: &str, + text: &str, + ) -> PublicProjectionPrivacyClassification { + if text.contains("private family detail") { + return PublicProjectionPrivacyClassification::Suspicious { + reason: String::from("matched fake private phrase"), + }; + } + + PublicProjectionPrivacyClassification::Allow + } + } + + struct UnavailableClassifier; + impl PublicProjectionPrivacyClassifier for UnavailableClassifier { + fn classify_public_projection_text( + &self, + _field_name: &str, + _text: &str, + ) -> PublicProjectionPrivacyClassification { + PublicProjectionPrivacyClassification::Unavailable { + reason: String::from("fake classifier unavailable"), + } + } + } fn progress_record() -> LinearExecutionEventRecord { LinearExecutionEventRecord::new( @@ -581,4 +842,88 @@ mod tests { assert!(error.contains("belongs in private execution events")); } } + + #[test] + fn privacy_classifier_allows_public_projection_text() { + let mut record = progress_record(); + + record.phase = Some(String::from("implementing")); + record.summary = Some(String::from("Execution phase: implementing.")); + + let projection = + super::linear_execution_event_public_projection("", &record, &AllowingClassifier); + + assert!(!projection.classifier_withheld_text); + assert_eq!(projection.record.summary.as_deref(), Some("Execution phase: implementing.")); + + super::validate_linear_execution_event_record(&projection.record) + .expect("allowed projection should remain valid"); + } + + #[test] + fn privacy_classifier_replaces_suspicious_required_summary_and_skips_optional_text() { + let mut record = progress_record(); + + record.phase = Some(String::from("implementing")); + record.summary = Some(String::from("Execution phase includes private family detail.")); + record.raw_error = Some(String::from("raw private family detail")); + + let projection = super::linear_execution_event_public_projection( + "comment has private family detail", + &record, + &SuspiciousWordClassifier, + ); + + assert!(projection.classifier_withheld_text); + assert_eq!( + projection.record.summary.as_deref(), + Some(super::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_SUMMARY) + ); + assert!(projection.record.raw_error.is_none()); + assert_eq!(projection.body, super::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_COMMENT_BODY); + + super::validate_linear_execution_event_record(&projection.record) + .expect("classified projection should remain valid"); + } + + #[test] + fn unavailable_privacy_classifier_fails_closed_with_fixed_public_fields() { + let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: "decodex", + issue_id: "issue-id", + issue_identifier: "XY-519", + run_id: "xy-519-attempt-1", + attempt_number: 1, + }, + "terminal_failure", + String::from("2026-05-25T00:00:00Z"), + "anchor", + ); + + record.error_class = Some(String::from("repo_gate_failed")); + record.next_action = Some(String::from("inspect the failed command")); + record.blockers = Some(vec![String::from("repo gate failed")]); + record.evidence = Some(vec![String::from("cargo make test failed")]); + + let projection = + super::linear_execution_event_public_projection("", &record, &UnavailableClassifier); + + assert!(projection.classifier_withheld_text); + assert_eq!( + projection.record.next_action.as_deref(), + Some(super::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_ACTION) + ); + assert_eq!( + projection.record.blockers.as_deref(), + Some([String::from(super::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL)].as_slice()) + ); + assert_eq!( + projection.record.evidence.as_deref(), + Some([String::from(super::PRIVACY_CLASSIFIER_WITHHELD_PUBLIC_DETAIL)].as_slice()) + ); + + super::validate_linear_execution_event_record(&projection.record) + .expect("unavailable classifier fallback should remain valid"); + } } diff --git a/decodex.example.toml b/decodex.example.toml index 862e02f37..0b36a5623 100644 --- a/decodex.example.toml +++ b/decodex.example.toml @@ -18,6 +18,13 @@ internal_review_mode = "loop" # `[codex.accounts].fixed_account` in `~/.codex/decodex/config.toml`. # [codex.accounts] +# Optional local-only classifier for Linear public projection text. +# The classifier is a secondary guard; schema-controlled public projections remain +# the primary privacy boundary. Omit this block to disable classifier calls. +# [privacy_classifier] +# endpoint = "http://127.0.0.1:9123/classify" +# timeout_ms = 1000 + # Required project paths. Project configs are managed outside checkouts, for example under # `~/.codex/decodex/projects//project.toml`. # Omit `worktree_root` to use `/.worktrees`. diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index a46f2c834..515307fb3 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -111,6 +111,11 @@ token_env_var = "GITHUB_TOKEN" internal_review_mode = "prompt" external_review_enabled = false +# Optional secondary public-projection privacy guard. +# [privacy_classifier] +# endpoint = "http://127.0.0.1:9123/classify" +# timeout_ms = 1000 + [paths] repo_root = "/path/to/hack-ink/decodex" ``` @@ -127,6 +132,9 @@ Notes: - For the self-dogfood pilot, use `codex.internal_review_mode = "loop"` for the runtime-owned self-review checkpoint loop, `"prompt"` to add only `Review your work repeatedly and fix any logic bugs until no new issues are found.`, or `"off"` to skip internal self-review. If omitted, the default is `"loop"`. - Keep `codex.external_review_enabled = false` when the retained lane should skip the runtime-owned `@codex review` request and rely on the PR-backed handoff plus the normal PR landing checks. - With `codex.external_review_enabled = false`, a one-shot `decodex run` may continue draining the same retained lane after review handoff if the retained landing gates are already satisfied. If those gates are still pending, the run exits cleanly at the retained waiting boundary instead of spinning. +- `[privacy_classifier]` is optional and disabled when omitted. If enabled, `endpoint` + must be an operator-managed loopback HTTP service; Decodex sends only rendered + public projection text fields to it, never private runtime evidence. - Automatic intake is driven by the service-scoped Linear label `decodex:queued:` derived from the registered project config `service_id`. Keep the pilot bounded by applying that label only to the small issue set you want `decodex` to own. ## Target repository contract diff --git a/docs/spec/linear-execution-ledger.md b/docs/spec/linear-execution-ledger.md index 0995a18d6..fbb4a7f89 100644 --- a/docs/spec/linear-execution-ledger.md +++ b/docs/spec/linear-execution-ledger.md @@ -86,6 +86,15 @@ activity markers. Linear records should continue to use public collaboration identifiers such as PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths. +Decodex may additionally run public projection free-text through an optional local +privacy classifier before publishing the Linear comment. That classifier is a secondary +semantic warning layer after schema allowlisting and the deterministic public-text +guard. It must receive only fields already selected for the public projection, never +the private runtime ledger or full checkpoint payload. If the configured local +classifier reports suspicious text or is unavailable, Decodex must fail closed by +omitting optional public text fields or replacing required public text fields with a +fixed public-safe summary. + Agent-requested manual-attention comments are not arbitrary Linear comment bodies. They are `needs_attention` ledger records rendered from the allowlisted `issue_comment` kind `manual_attention`. The agent supplies only structured public diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 166c58a71..5b70a5267 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -226,6 +226,16 @@ path, and PR URL. Raw checkpoint focus, next action, blockers, evidence, verific local head evidence, host-local paths, identity-routing details, account details, and token names must stay out of Linear. +If the project configures a local public-projection privacy classifier, Decodex applies +that classifier only after the schema-controlled public projection has been rendered. +The classifier receives public projection text fields, not private execution events or +the full checkpoint payload. It is a secondary semantic guard: schema allowlisting and +the deterministic public-text guard remain the primary privacy boundary. Suspicious +classifier verdicts and classifier-unavailable results fail closed by withholding +optional public text or replacing required public text with fixed public-safe fallback +text; private execution-event persistence still succeeds independently of Linear +projection classification. + The local `linear_execution_events` table remains the public mirror cache for rendered Linear records. It is not the private evidence source. Repeated checkpoint calls that only change private payload fields must append private execution events but must not @@ -334,6 +344,11 @@ The local runtime store is the global Decodex SQLite database for one local inst Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. +`project.toml` may also configure `[privacy_classifier]` with a loopback HTTP +`endpoint` and bounded `timeout_ms` for an operator-managed local classifier runtime. +Remote classifier endpoints are invalid. When omitted, the classifier adapter is +disabled and public projections rely on the schema and deterministic guard only. + The runtime database stores at least: - registered projects and config fingerprints diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 22947de68..dc6d9c06d 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -105,6 +105,14 @@ In either invalid case, `decodex` must fail the attempt rather than infer which and `pr_url`. It must not include raw `focus`, `next_action`, `blockers`, `evidence`, `verification`, local head evidence, host-local paths, identity-routing details, account details, token names, or other private runtime evidence. +- When a project configures a local public-projection privacy classifier, Decodex must + run only Linear projection text fields through that local classifier before writing + the Linear comment. The classifier is not the primary boundary and must not receive + raw checkpoint `focus`, `next_action`, `blockers`, `evidence`, `verification`, + local runtime events, or other private ledger payloads. +- Suspicious or classifier-unavailable projection fields must fail closed: optional + fields are omitted, and required text fields are replaced with fixed public-safe + fallback text before any Linear mutation. - `issue_progress_checkpoint` must publish a new Linear projection only when the public lifecycle signal changes materially, such as the normalized phase or public branch/PR projection anchor changing. Repeated private evidence updates inside the