diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 73a8f4e..c16e065 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "b25bfc668c9ac086a09be0d69b2ca1e6a6ddae13", - "release_name": "dryrun-b25bfc668c9a", + "source_commit": "4336e3b85b5865fca9979c69ebb3906eab7601da", + "release_name": "dryrun-4336e3b85b58", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 0031af7..d043a31 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -948,7 +948,7 @@ fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result { fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; let stored_session = read_cli_session(&cwd)?; - let active_coordinator = args + let configured_coordinator = args .scope .coordinator .clone() @@ -961,7 +961,9 @@ fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { stored_session .as_ref() .map(|session| session.coordinator.clone()) - }) + }); + let active_coordinator = configured_coordinator + .clone() .unwrap_or_else(default_operator_endpoint); let tenant = effective_scope_value( &args.scope.tenant, @@ -995,6 +997,21 @@ fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { .or_else(|| stored_session.as_ref().map(|session| session.user.as_str())), "user", ); + let coordinator_account_status = configured_coordinator + .as_ref() + .map(|coordinator| { + coordinator_auth_status_summary(coordinator, &tenant, &project, &principal) + }) + .unwrap_or_else(|| { + json!({ + "checked": false, + "reason": "no project or session coordinator configured", + "suspension_known": false, + "account_status": "unknown", + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + }) + }); Ok(json!({ "command": "auth status", "active_coordinator": active_coordinator, @@ -1002,10 +1019,128 @@ fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { "tenant": tenant, "project": project, "session": auth_state_value(&cwd)?, + "coordinator_account_status": coordinator_account_status, "project_config": config, })) } +fn coordinator_auth_status_summary( + coordinator: &str, + tenant: &str, + project: &str, + principal: &str, +) -> Value { + let mut session = match JsonLineSession::connect(coordinator) { + Ok(session) => session, + Err(error) => { + let message = error.to_string(); + return json!({ + "checked": true, + "reachable": false, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(&message), + "error": message, + "next_actions": ["disasmer doctor", "check coordinator status"], + "coordinator_session_requests": 0, + }); + } + }; + let response = match session.request_allow_error(json!({ + "type": "auth_status", + "tenant": tenant, + "project": project, + "actor_user": principal, + })) { + Ok(response) => response, + Err(error) => { + let message = error.to_string(); + return json!({ + "checked": true, + "reachable": false, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(&message), + "error": message, + "next_actions": ["disasmer doctor", "check coordinator status"], + "coordinator_session_requests": session.requests(), + }); + } + }; + let coordinator_session_requests = session.requests(); + if response.get("type").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator rejected auth status"); + return json!({ + "checked": true, + "reachable": true, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(message), + "coordinator_response_type": "error", + "next_actions": ["disasmer doctor", "disasmer login --browser"], + "coordinator_session_requests": coordinator_session_requests, + }); + } + let suspended = response + .get("suspended") + .and_then(Value::as_bool) + .unwrap_or(false); + let disabled = response + .get("disabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let account_status = response + .get("account_status") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + if suspended { + "suspended" + } else if disabled { + "disabled" + } else { + "active" + } + .to_owned() + }); + let sanitized_reason = response.get("sanitized_reason").and_then(Value::as_str); + let next_actions = response + .get("next_actions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect::>(); + json!({ + "checked": true, + "reachable": true, + "source": "public_coordinator_api", + "account_status": account_status, + "suspension_known": true, + "suspended": suspended, + "disabled": disabled, + "sanitized_reason": sanitized_reason, + "next_actions": next_actions, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("auth_status"), + "coordinator_session_requests": coordinator_session_requests, + }) +} + fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result { logout_report(args, cwd, "auth logout") } @@ -2741,6 +2876,25 @@ fn human_report(value: &Value) -> String { if let Some(session) = value.get("session") { lines.push(format!("session: {}", compact_json(session))); } + if let Some(account) = value.get("coordinator_account_status") { + if let Some(checked) = account.get("checked").and_then(Value::as_bool) { + lines.push(format!("account status checked: {checked}")); + } + push_nested_string_field(&mut lines, account, "account_status", "account status"); + if let Some(suspended) = account.get("suspended").and_then(Value::as_bool) { + lines.push(format!("account suspended: {suspended}")); + } + if let Some(disabled) = account.get("disabled").and_then(Value::as_bool) { + lines.push(format!("account disabled: {disabled}")); + } + push_nested_string_field(&mut lines, account, "sanitized_reason", "account reason"); + if let Some(exposed) = account + .get("private_moderation_details_exposed") + .and_then(Value::as_bool) + { + lines.push(format!("private moderation details exposed: {exposed}")); + } + } if let Some(coordinator_selection) = value.get("coordinator") { if coordinator_selection.is_object() { lines.push(format!( @@ -7530,11 +7684,29 @@ mod tests { #[test] fn auth_status_reads_stored_cli_session_without_provider_tokens() { let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"auth_status""#)); + assert!(line.contains(r#""tenant":"tenant-session""#)); + assert!(line.contains(r#""project":"project-session""#)); + assert!(line.contains(r#""actor_user":"user-session""#)); + stream + .write_all( + br#"{"type":"auth_status","tenant":"tenant-session","project":"project-session","actor":"user-session","authenticated":true,"account_status":"active","suspended":false,"disabled":false,"sanitized_reason":null,"next_actions":[],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); write_cli_session( temp.path(), &StoredCliSession { kind: "human".to_owned(), - coordinator: "https://coord.example.test".to_owned(), + coordinator: addr.clone(), tenant: "tenant-session".to_owned(), project: "project-session".to_owned(), user: "user-session".to_owned(), @@ -7561,8 +7733,9 @@ mod tests { temp.path().to_path_buf(), ) .unwrap(); + server.join().unwrap(); - assert_eq!(report["active_coordinator"], "https://coord.example.test"); + assert_eq!(report["active_coordinator"], addr); assert_eq!(report["principal"], "user-session"); assert_eq!(report["tenant"], "tenant-session"); assert_eq!(report["project"], "project-session"); @@ -7576,6 +7749,97 @@ mod tests { assert_eq!(report["session"]["token_expiry_posture"], "expires_at"); assert_eq!(report["session"]["provider_tokens_exposed_to_cli"], false); assert_eq!(report["session"]["provider_tokens_exposed_to_nodes"], false); + assert_eq!(report["coordinator_account_status"]["checked"], true); + assert_eq!( + report["coordinator_account_status"]["account_status"], + "active" + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); + } + + #[test] + fn auth_status_queries_coordinator_account_state_without_private_moderation_details() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"auth_status""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""project":"project-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + stream + .write_all( + br#"{"type":"auth_status","tenant":"tenant-live","project":"project-live","actor":"user-live","authenticated":true,"account_status":"suspended","suspended":true,"disabled":false,"sanitized_reason":"account or tenant is suspended by hosted policy","next_actions":["contact the hosted operator"],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false,"abuse_score":99,"moderation_notes":"private moderation note"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "auth status"); + assert_eq!(report["coordinator_account_status"]["checked"], true); + assert_eq!(report["coordinator_account_status"]["reachable"], true); + assert_eq!( + report["coordinator_account_status"]["source"], + "public_coordinator_api" + ); + assert_eq!( + report["coordinator_account_status"]["account_status"], + "suspended" + ); + assert_eq!(report["coordinator_account_status"]["suspended"], true); + assert_eq!(report["coordinator_account_status"]["disabled"], false); + assert_eq!( + report["coordinator_account_status"]["sanitized_reason"], + "account or tenant is suspended by hosted policy" + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["signup_failure_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["coordinator_response_type"], + "auth_status" + ); + assert_eq!( + report["coordinator_account_status"]["coordinator_session_requests"], + 1 + ); + let serialized = serde_json::to_string(&report).unwrap(); + assert!(!serialized.contains("abuse_score")); + assert!(!serialized.contains("moderation_notes")); + assert!(!serialized.contains("private moderation note")); + + let rendered = human_report(&report); + assert!(rendered.contains("account status: suspended")); + assert!(rendered.contains("account suspended: true")); + assert!(rendered.contains("private moderation details exposed: false")); + assert!(!rendered.contains("private moderation note")); } #[test] diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index f3d390d..973f906 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -28,6 +28,11 @@ const DEBUG_CONTROL_READ_BYTES: u64 = 1024; #[serde(tag = "type", rename_all = "snake_case")] pub enum CoordinatorRequest { Ping, + AuthStatus { + tenant: String, + project: String, + actor_user: String, + }, AdminStatus { tenant: String, actor_user: String, @@ -489,6 +494,19 @@ pub enum CoordinatorResponse { Pong { epoch: u64, }, + AuthStatus { + tenant: TenantId, + project: ProjectId, + actor: UserId, + authenticated: bool, + account_status: String, + suspended: bool, + disabled: bool, + sanitized_reason: Option, + next_actions: Vec, + private_moderation_details_exposed: bool, + signup_failure_details_exposed: bool, + }, AdminStatus { tenant: TenantId, actor: UserId, @@ -781,6 +799,40 @@ impl CoordinatorService { CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { epoch: self.coordinator.coordinator_epoch(), }), + CoordinatorRequest::AuthStatus { + tenant, + project, + actor_user, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let suspended = self.coordinator.tenant_suspended(&tenant); + let account_status = if suspended { "suspended" } else { "active" }.to_owned(); + let sanitized_reason = + suspended.then(|| "account or tenant is suspended by hosted policy".to_owned()); + let next_actions = if suspended { + vec![ + "disasmer auth status --json".to_owned(), + "contact the hosted operator or use a self-hosted coordinator".to_owned(), + ] + } else { + Vec::new() + }; + Ok(CoordinatorResponse::AuthStatus { + tenant, + project, + actor, + authenticated: true, + account_status, + suspended, + disabled: false, + sanitized_reason, + next_actions, + private_moderation_details_exposed: false, + signup_failure_details_exposed: false, + }) + } CoordinatorRequest::AdminStatus { tenant, actor_user } => { let tenant = TenantId::new(tenant); let actor = UserId::new(actor_user); @@ -2909,6 +2961,39 @@ mod tests { fn service_reports_and_enforces_public_admin_tenant_suspension() { let mut service = CoordinatorService::new(7); + let CoordinatorResponse::AuthStatus { + tenant, + project, + actor, + authenticated, + account_status, + suspended, + disabled, + sanitized_reason, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected auth status"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(project, ProjectId::from("project")); + assert_eq!(actor, UserId::from("user")); + assert!(authenticated); + assert_eq!(account_status, "active"); + assert!(!suspended); + assert!(!disabled); + assert!(sanitized_reason.is_none()); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + let CoordinatorResponse::AdminStatus { tenant, actor, @@ -2957,6 +3042,38 @@ mod tests { }; assert!(suspended); + let CoordinatorResponse::AuthStatus { + account_status, + suspended, + disabled, + sanitized_reason, + next_actions, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected suspended auth status"); + }; + assert_eq!(account_status, "suspended"); + assert!(suspended); + assert!(!disabled); + assert_eq!( + sanitized_reason.as_deref(), + Some("account or tenant is suspended by hosted policy") + ); + assert!(next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + let create = service .handle_request(CoordinatorRequest::CreateProject { tenant: "tenant".to_owned(), diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 964acf4..a9ff626 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -227,6 +227,7 @@ for (const [name, pattern] of [ ["CLI non-interactive browser login coverage", /fn browser_login_non_interactive_fails_before_opening_browser\(\)/], ["CLI stored browser session coverage", /fn stored_browser_login_session_omits_provider_token_values\(\)/], ["CLI auth status session-file coverage", /fn auth_status_reads_stored_cli_session_without_provider_tokens\(\)/], + ["CLI auth status account privacy coverage", /fn auth_status_queries_coordinator_account_state_without_private_moderation_details\(\)/], ["CLI version coverage", /fn top_level_version_is_available\(\)/], ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], @@ -439,6 +440,26 @@ expect( "CLI exposes quota machine-error posture", /fn cli_error_summary_for_category[\s\S]*resource_category[\s\S]*quota_error_resource_category[\s\S]*community_tier_language[\s\S]*community_tier_label[\s\S]*private_abuse_heuristics_exposed[\s\S]*fn quota_error_resource_category[\s\S]*resource limit exceeded for / ); +expect( + coordinator, + "coordinator exposes sanitized auth status API", + /AuthStatus \{[\s\S]*tenant: String[\s\S]*project: String[\s\S]*actor_user: String[\s\S]*AuthStatus \{[\s\S]*account_status: String[\s\S]*suspended: bool[\s\S]*disabled: bool[\s\S]*private_moderation_details_exposed: bool[\s\S]*signup_failure_details_exposed: bool/ +); +expect( + cli, + "CLI auth status queries public account state", + /fn coordinator_auth_status_summary[\s\S]*"type": "auth_status"[\s\S]*"private_moderation_details_exposed": false[\s\S]*"signup_failure_details_exposed": false/ +); +expect( + cli, + "CLI auth status does not expose raw private moderation response fields", + /fn auth_status_queries_coordinator_account_state_without_private_moderation_details[\s\S]*abuse_score[\s\S]*moderation_notes[\s\S]*!serialized\.contains\("abuse_score"\)[\s\S]*!serialized\.contains\("moderation_notes"\)/ +); +expect( + cli, + "CLI renders sanitized account status", + /coordinator_account_status[\s\S]*account status checked: \{checked\}[\s\S]*account status[\s\S]*private moderation details exposed/ +); expect( cli, "CLI renders community tier wording", diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js index df98100..7c25055 100644 --- a/scripts/cli-output-mode-smoke.js +++ b/scripts/cli-output-mode-smoke.js @@ -92,6 +92,12 @@ const authJson = json(["auth", "status", "--json"], { assert.strictEqual(authJson.session.kind, "human"); assert.strictEqual(authJson.session.token_expiry_posture, "expires_at"); assert.strictEqual(authJson.session.expires_at, "2026-07-04T00:00:00Z"); +assert.strictEqual(authJson.coordinator_account_status.checked, false); +assert.strictEqual(authJson.coordinator_account_status.account_status, "unknown"); +assert.strictEqual( + authJson.coordinator_account_status.private_moderation_details_exposed, + false +); const inspectHuman = disasmer(["bundle", "inspect", "--project", project]); assertHuman("bundle inspect", inspectHuman, [