diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index ae3c1e2..ff503bb 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "3cea82a67d5b5aca88470c0d06677b8b4237da11", - "release_name": "dryrun-3cea82a67d5b", + "source_commit": "96f60dfba4756607edf60c08519050e2e4c2bf37", + "release_name": "dryrun-96f60dfba475", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 2d3585f..a840bb4 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -618,12 +618,16 @@ struct NodeAttachPlan { node: String, coordinator: Option, capabilities: NodeCapabilities, + grant_disclosures: Vec, enrollment: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] struct NodeAttachReport { + command: String, + node: String, plan: NodeAttachPlan, + grant_disclosures: Vec, boundary: NodeAttachBoundaryEvidence, coordinator_response: Value, heartbeat_response: Value, @@ -638,6 +642,15 @@ struct NodeAttachBoundaryEvidence { coordinator_session_requests: u64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct CapabilityGrantDisclosure { + capability: Capability, + grant: String, + description: String, + risk: String, + coordinator_policy_limited: bool, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeEnrollmentPlan { grant: String, @@ -1919,6 +1932,32 @@ fn human_report(value: &Value) -> String { } } } + if let Some(disclosures) = value + .get("grant_disclosures") + .and_then(Value::as_array) + .filter(|disclosures| !disclosures.is_empty()) + { + for disclosure in disclosures { + let grant = disclosure + .get("grant") + .and_then(Value::as_str) + .unwrap_or("capability"); + let description = disclosure + .get("description") + .and_then(Value::as_str) + .unwrap_or("capability grant"); + let policy = if disclosure + .get("coordinator_policy_limited") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "policy-limited" + } else { + "unbounded" + }; + lines.push(format!("grant {grant}: {description} ({policy})")); + } + } lines.dedup(); lines.join("\n") @@ -3768,15 +3807,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 grant_disclosures = capability_grant_disclosures(&capabilities); NodeAttachPlan { node, coordinator: args.coordinator, capabilities, + grant_disclosures, enrollment, } } +fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec { + let mut disclosures = Vec::new(); + let mut push = |capability: Capability, grant: &str, description: &str, risk: &str| { + if capabilities.capabilities.contains(&capability) { + disclosures.push(CapabilityGrantDisclosure { + capability, + grant: grant.to_owned(), + description: description.to_owned(), + risk: risk.to_owned(), + coordinator_policy_limited: true, + }); + } + }; + + push( + Capability::Command, + "native_command_execution", + "placed tasks may run native commands on this node", + "local process execution under the node account", + ); + push( + Capability::WindowsCommandDev, + "native_command_execution", + "placed tasks may run Windows developer commands on this node", + "local process execution under the node account", + ); + push( + Capability::SourceFilesystem, + "source_access", + "placed tasks may read the local project/source checkout exposed by this node", + "broad local source visibility", + ); + push( + Capability::SourceGit, + "source_access", + "placed tasks may use Git-backed source access exposed by this node", + "source-provider visibility", + ); + push( + Capability::Network, + "network_access", + "placed tasks may use outbound network access from this node", + "network egress from the node environment", + ); + push( + Capability::HostFilesystem, + "host_filesystem_access", + "placed tasks may access configured host filesystem mounts", + "host file visibility outside the project checkout", + ); + push( + Capability::Secrets, + "secret_access", + "placed tasks may receive configured secret material", + "secret exposure to authorized task code", + ); + push( + Capability::InboundPorts, + "inbound_ports", + "placed tasks may expose inbound ports from this node", + "network service exposure from the node environment", + ); + push( + Capability::ArbitrarySyscalls, + "arbitrary_syscalls", + "placed tasks may use broader host syscall surface", + "reduced host isolation", + ); + + disclosures.sort_by(|left, right| { + left.grant + .cmp(&right.grant) + .then_with(|| left.capability.cmp(&right.capability)) + }); + disclosures +} + fn execute_node_attach(args: AttachArgs) -> Result { let coordinator = args .coordinator @@ -3831,6 +3949,9 @@ fn execute_node_attach(args: AttachArgs) -> Result { }))?; Ok(NodeAttachReport { + command: "node attach".to_owned(), + node: plan.node.clone(), + grant_disclosures: plan.grant_disclosures.clone(), plan, boundary: NodeAttachBoundaryEvidence { cli_contacted_coordinator: true, @@ -3922,6 +4043,11 @@ fn parse_capability(cap: &str) -> Option { "rootless-podman" => Some(Capability::RootlessPodman), "source-filesystem" => Some(Capability::SourceFilesystem), "source-git" => Some(Capability::SourceGit), + "host-filesystem" => Some(Capability::HostFilesystem), + "network" => Some(Capability::Network), + "secrets" => Some(Capability::Secrets), + "inbound-ports" => Some(Capability::InboundPorts), + "arbitrary-syscalls" => Some(Capability::ArbitrarySyscalls), "vfs-artifacts" => Some(Capability::VfsArtifacts), "wasmtime" => Some(Capability::Wasmtime), "windows-command-dev" => Some(Capability::WindowsCommandDev), @@ -4006,6 +4132,54 @@ mod tests { assert!(!plan.capabilities.arch.is_empty()); } + #[test] + fn node_attach_discloses_dangerous_capability_grants() { + let Cli { + command: + Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--cap", + "network", + "--cap", + "host-filesystem", + "--cap", + "secrets", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + let grants = plan + .grant_disclosures + .iter() + .map(|disclosure| disclosure.grant.as_str()) + .collect::>(); + + assert!(grants.contains(&"native_command_execution")); + assert!(grants.contains(&"source_access")); + assert!(grants.contains(&"network_access")); + assert!(grants.contains(&"host_filesystem_access")); + assert!(grants.contains(&"secret_access")); + assert!(plan + .grant_disclosures + .iter() + .all(|disclosure| disclosure.coordinator_policy_limited)); + + let rendered = human_report(&json!({ + "command": "node attach", + "node": plan.node, + "grant_disclosures": plan.grant_disclosures, + })); + assert!(rendered.contains("grant native_command_execution")); + assert!(rendered.contains("grant network_access")); + assert!(rendered.contains("policy-limited")); + } + #[test] fn agents_can_select_hosted_with_public_key_identity() { let args = RunArgs { diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index cf70c29..c10c6fc 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -115,6 +115,7 @@ for (const [name, pattern] of [ ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], + ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], ["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], ["task event summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)/], @@ -197,6 +198,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 grant disclosures", + /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ +); +expect( + cli, + "CLI parses dangerous capability overrides", + /"host-filesystem"[\s\S]*Capability::HostFilesystem[\s\S]*"network"[\s\S]*Capability::Network[\s\S]*"secrets"[\s\S]*Capability::Secrets/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],