diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index b1fdef9..0f82e11 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "13dc821c5a4ff2b093b777374ebf510197ab5500", - "release_name": "dryrun-13dc821c5a4f", + "source_commit": "4a860d72efe34b376c4b08762555bee3fe3105f9", + "release_name": "dryrun-4a860d72efe3", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index f8a8e5d..660250a 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; @@ -10,8 +10,9 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use clap::{Args, Parser, Subcommand}; use disasmer_core::{ diagnose_environment_references, BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, - Capability, CliLoginFlow, Digest, EnvironmentReference, LimitKind, NodeCapabilities, - ProjectModel, ResourceLimits, SelectedInput, SourceProviderKind, SourceProviderManifest, + Capability, CliLoginFlow, Digest, EnvironmentBackend, EnvironmentReference, LimitKind, + NodeCapabilities, ProjectModel, ResourceLimits, SelectedInput, SourceProviderKind, + SourceProviderManifest, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -678,6 +679,7 @@ struct NodeAttachPlan { node: String, coordinator: Option, capabilities: NodeCapabilities, + detection: NodeAttachDetectionEvidence, grant_disclosures: Vec, enrollment: Option, } @@ -711,6 +713,32 @@ struct CapabilityGrantDisclosure { coordinator_policy_limited: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct NodeAttachDetectionEvidence { + auto_detected: bool, + os: disasmer_core::Os, + arch: String, + command_backend: String, + command_backend_available: bool, + container_backend: Option, + container_backend_reported: bool, + container_backend_available: bool, + source_provider_backends: Vec, + manual_capability_overrides_allowed: bool, + manual_capability_overrides: Vec, + recognized_capability_overrides: Vec, + unrecognized_capability_overrides: Vec, + os_arch_capabilities_require_manual_flags: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct SourceProviderBackendStatus { + provider: String, + detected: bool, + available: bool, + reason: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeEnrollmentPlan { grant: String, @@ -2794,6 +2822,12 @@ fn human_report(value: &Value) -> String { } } } + if let Some(detection) = value + .pointer("/plan/detection") + .or_else(|| value.get("detection")) + { + push_node_attach_detection(&mut lines, detection); + } if let Some(disclosures) = value .get("grant_disclosures") .and_then(Value::as_array) @@ -2825,6 +2859,64 @@ fn human_report(value: &Value) -> String { lines.join("\n") } +fn push_node_attach_detection(lines: &mut Vec, detection: &Value) { + push_nested_string_field(lines, detection, "os", "node os"); + push_nested_string_field(lines, detection, "arch", "node arch"); + push_nested_string_field(lines, detection, "command_backend", "command backend"); + if let Some(backend) = detection.get("container_backend").and_then(Value::as_str) { + let available = detection + .get("container_backend_available") + .and_then(Value::as_bool) + .unwrap_or(false); + lines.push(format!( + "container backend: {backend} ({})", + if available { "available" } else { "reported" } + )); + } else if detection + .get("container_backend_reported") + .and_then(Value::as_bool) + .unwrap_or(false) + { + lines.push("container backend: reported".to_owned()); + } + if let Some(providers) = detection + .get("source_provider_backends") + .and_then(Value::as_array) + { + let entries = providers + .iter() + .filter_map(|provider| { + let name = provider.get("provider").and_then(Value::as_str)?; + let state = if provider + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "available" + } else { + "detected" + }; + Some(format!("{name}={state}")) + }) + .collect::>(); + if !entries.is_empty() { + lines.push(format!("source providers: {}", entries.join(", "))); + } + } + if let Some(overrides) = detection + .get("manual_capability_overrides") + .and_then(Value::as_array) + { + let overrides = overrides + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !overrides.is_empty() { + lines.push(format!("capability overrides: {}", overrides.join(", "))); + } + } +} + fn push_string_field(lines: &mut Vec, value: &Value, key: &str, label: &str) { if let Some(text) = value.get(key).and_then(Value::as_str) { lines.push(format!("{label}: {text}")); @@ -5748,11 +5840,18 @@ fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback fn attach_plan(args: AttachArgs) -> NodeAttachPlan { let mut capabilities = NodeCapabilities::detect_current(); + let mut recognized_capability_overrides = Vec::new(); + let mut unrecognized_capability_overrides = Vec::new(); for cap in &args.caps { if let Some(parsed) = parse_capability(&cap) { + recognized_capability_overrides.push(parsed.clone()); capabilities.capabilities.insert(parsed); + } else { + unrecognized_capability_overrides.push(cap.clone()); } } + recognized_capability_overrides.sort(); + recognized_capability_overrides.dedup(); let node = args.node.unwrap_or_else(default_node_id); let public_key = args .public_key @@ -5762,17 +5861,94 @@ fn attach_plan(args: AttachArgs) -> NodeAttachPlan { public_key_fingerprint: Digest::sha256(public_key), exchanges_short_lived_grant_for_long_lived_node_identity: true, }); + let detection = node_attach_detection_evidence( + &capabilities, + args.caps, + recognized_capability_overrides, + unrecognized_capability_overrides, + ); let grant_disclosures = capability_grant_disclosures(&capabilities); NodeAttachPlan { node, coordinator: args.coordinator, capabilities, + detection, grant_disclosures, enrollment, } } +fn node_attach_detection_evidence( + capabilities: &NodeCapabilities, + manual_capability_overrides: Vec, + recognized_capability_overrides: Vec, + unrecognized_capability_overrides: Vec, +) -> NodeAttachDetectionEvidence { + let command_backend_available = capabilities.capabilities.contains(&Capability::Command); + let container_backend_reported = capabilities + .environment_backends + .contains(&EnvironmentBackend::Container); + let container_backend = container_backend_reported.then(|| "rootless-podman".to_owned()); + let container_backend_available = container_backend_reported + && capabilities + .capabilities + .contains(&Capability::RootlessPodman) + && command_available("podman"); + + NodeAttachDetectionEvidence { + auto_detected: true, + os: capabilities.os.clone(), + arch: capabilities.arch.clone(), + command_backend: if command_backend_available { + "native-command".to_owned() + } else { + "unavailable".to_owned() + }, + command_backend_available, + container_backend, + container_backend_reported, + container_backend_available, + source_provider_backends: source_provider_backend_statuses(capabilities), + manual_capability_overrides_allowed: true, + manual_capability_overrides, + recognized_capability_overrides, + unrecognized_capability_overrides, + os_arch_capabilities_require_manual_flags: false, + } +} + +fn source_provider_backend_statuses( + capabilities: &NodeCapabilities, +) -> Vec { + let mut statuses = BTreeMap::new(); + for provider in &capabilities.source_providers { + let available = match provider.as_str() { + "filesystem" => capabilities + .capabilities + .contains(&Capability::SourceFilesystem), + "git" => command_available("git"), + _ => true, + }; + statuses.insert( + provider.clone(), + SourceProviderBackendStatus { + provider: provider.clone(), + detected: true, + available, + reason: if available { + "detected by local node capability probe".to_owned() + } else { + format!( + "source provider `{provider}` was detected but its local helper is missing" + ) + }, + }, + ); + } + statuses.into_values().collect() +} + fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec { let mut disclosures = Vec::new(); let mut push = |capability: Capability, grant: &str, description: &str, risk: &str| { @@ -6225,6 +6401,27 @@ mod tests { .capabilities .contains(&Capability::QuicDirect)); assert!(!plan.capabilities.arch.is_empty()); + assert!(plan.detection.auto_detected); + assert_eq!(plan.detection.os, plan.capabilities.os); + assert_eq!(plan.detection.arch, plan.capabilities.arch); + assert_eq!(plan.detection.command_backend, "native-command"); + assert!(plan.detection.command_backend_available); + assert!(plan.detection.manual_capability_overrides_allowed); + assert_eq!( + plan.detection.manual_capability_overrides, + vec!["quic-direct".to_owned()] + ); + assert!(plan + .detection + .recognized_capability_overrides + .contains(&Capability::QuicDirect)); + assert!(plan.detection.unrecognized_capability_overrides.is_empty()); + assert!(!plan.detection.os_arch_capabilities_require_manual_flags); + assert!(plan + .detection + .source_provider_backends + .iter() + .any(|provider| provider.provider == "filesystem" && provider.detected)); } #[test] diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 7c3def1..f1f18f7 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -37,6 +37,7 @@ const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); const browserLoginFlowSmoke = read("scripts/cli-browser-login-flow-smoke.js"); +const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); expect( @@ -250,6 +251,7 @@ for (const [name, pattern] of [ ["CLI locality failure classifier", /fn classify_cli_error_message\(message: &str\)[\s\S]*message_mentions_locality_failure\(&message\)[\s\S]*return "connectivity"/], ["CLI locality failure report helper", /fn task_locality_failure_from_reason\(reason: &Value\) -> Value[\s\S]*coordinator_bulk_relay_used[\s\S]*safe_next_actions/], ["CLI locality failure human output", /fn push_task_locality_failures\(lines: &mut Vec, tasks: &\[Value\]\)[\s\S]*locality \{task_name\}/], + ["node attach auto-detection coverage", /fn node_attach_detects_and_accepts_capability_overrides\(\)[\s\S]*detection\.auto_detected[\s\S]*command_backend[\s\S]*source_provider_backends/], ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], ["node enroll public API grant coverage", /fn node_enroll_reports_short_lived_public_api_grant\(\)/], ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], @@ -280,6 +282,11 @@ expect( "browser login smoke rejects provider token persistence", /assert\.doesNotMatch\([\s\S]*access_token\|refresh_token\|id_token/ ); +expect( + nodeAttachSmoke, + "node attach smoke verifies auto-detection evidence", + /plan\.detection\.auto_detected[\s\S]*plan\.detection\.command_backend[\s\S]*source_provider_backends/ +); expect( coordinator, @@ -356,6 +363,16 @@ expect( "CLI sends agent workflow actor fields", /fn add_workflow_actor_fields[\s\S]*actor_agent[\s\S]*agent_public_key_fingerprint/ ); +expect( + cli, + "CLI exposes node attach detection evidence", + /struct NodeAttachDetectionEvidence[\s\S]*command_backend[\s\S]*container_backend[\s\S]*source_provider_backends[\s\S]*manual_capability_overrides[\s\S]*os_arch_capabilities_require_manual_flags/ +); +expect( + cli, + "CLI renders node attach detection evidence", + /fn push_node_attach_detection[\s\S]*command backend[\s\S]*container backend[\s\S]*source providers[\s\S]*capability overrides/ +); expect( cli, "CLI exposes node attach grant disclosures", diff --git a/scripts/node-attach-smoke.js b/scripts/node-attach-smoke.js index bb193bd..e0f2508 100755 --- a/scripts/node-attach-smoke.js +++ b/scripts/node-attach-smoke.js @@ -212,6 +212,23 @@ function runAttachedNodeWork(addr) { assert.ok(report.plan.capabilities.arch.length > 0); assert.ok(report.plan.capabilities.source_providers.includes("filesystem")); assert.ok(report.plan.capabilities.capabilities.includes("QuicDirect")); + assert.strictEqual(report.plan.detection.auto_detected, true); + assert.strictEqual(report.plan.detection.arch, report.plan.capabilities.arch); + assert.deepStrictEqual(report.plan.detection.manual_capability_overrides, ["quic-direct"]); + assert( + report.plan.detection.recognized_capability_overrides.includes("QuicDirect") + ); + assert.strictEqual( + report.plan.detection.os_arch_capabilities_require_manual_flags, + false + ); + assert.strictEqual(report.plan.detection.command_backend, "native-command"); + assert.strictEqual(report.plan.detection.command_backend_available, true); + assert( + report.plan.detection.source_provider_backends.some( + (provider) => provider.provider === "filesystem" && provider.detected + ) + ); assert.strictEqual(report.boundary.cli_contacted_coordinator, true); assert.strictEqual(report.boundary.used_enrollment_exchange, true); assert.strictEqual(report.boundary.coordinator_session_requests, 3);