Disclose node attach capability grants

This commit is contained in:
Michel Paulissen 2026-07-03 22:04:13 +02:00
parent c514ab7f81
commit fe6c2d5042
3 changed files with 187 additions and 2 deletions

View file

@ -1,7 +1,7 @@
{ {
"kind": "disasmer-filtered-public-tree", "kind": "disasmer-filtered-public-tree",
"source_commit": "3cea82a67d5b5aca88470c0d06677b8b4237da11", "source_commit": "96f60dfba4756607edf60c08519050e2e4c2bf37",
"release_name": "dryrun-3cea82a67d5b", "release_name": "dryrun-96f60dfba475",
"filtered_out": [ "filtered_out": [
"private/**", "private/**",
"experiments/**", "experiments/**",

View file

@ -618,12 +618,16 @@ struct NodeAttachPlan {
node: String, node: String,
coordinator: Option<String>, coordinator: Option<String>,
capabilities: NodeCapabilities, capabilities: NodeCapabilities,
grant_disclosures: Vec<CapabilityGrantDisclosure>,
enrollment: Option<NodeEnrollmentPlan>, enrollment: Option<NodeEnrollmentPlan>,
} }
#[derive(Clone, Debug, PartialEq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
struct NodeAttachReport { struct NodeAttachReport {
command: String,
node: String,
plan: NodeAttachPlan, plan: NodeAttachPlan,
grant_disclosures: Vec<CapabilityGrantDisclosure>,
boundary: NodeAttachBoundaryEvidence, boundary: NodeAttachBoundaryEvidence,
coordinator_response: Value, coordinator_response: Value,
heartbeat_response: Value, heartbeat_response: Value,
@ -638,6 +642,15 @@ struct NodeAttachBoundaryEvidence {
coordinator_session_requests: u64, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct NodeEnrollmentPlan { struct NodeEnrollmentPlan {
grant: String, 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.dedup();
lines.join("\n") lines.join("\n")
@ -3768,15 +3807,94 @@ fn attach_plan(args: AttachArgs) -> NodeAttachPlan {
public_key_fingerprint: Digest::sha256(public_key), public_key_fingerprint: Digest::sha256(public_key),
exchanges_short_lived_grant_for_long_lived_node_identity: true, exchanges_short_lived_grant_for_long_lived_node_identity: true,
}); });
let grant_disclosures = capability_grant_disclosures(&capabilities);
NodeAttachPlan { NodeAttachPlan {
node, node,
coordinator: args.coordinator, coordinator: args.coordinator,
capabilities, capabilities,
grant_disclosures,
enrollment, enrollment,
} }
} }
fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec<CapabilityGrantDisclosure> {
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<NodeAttachReport> { fn execute_node_attach(args: AttachArgs) -> Result<NodeAttachReport> {
let coordinator = args let coordinator = args
.coordinator .coordinator
@ -3831,6 +3949,9 @@ fn execute_node_attach(args: AttachArgs) -> Result<NodeAttachReport> {
}))?; }))?;
Ok(NodeAttachReport { Ok(NodeAttachReport {
command: "node attach".to_owned(),
node: plan.node.clone(),
grant_disclosures: plan.grant_disclosures.clone(),
plan, plan,
boundary: NodeAttachBoundaryEvidence { boundary: NodeAttachBoundaryEvidence {
cli_contacted_coordinator: true, cli_contacted_coordinator: true,
@ -3922,6 +4043,11 @@ fn parse_capability(cap: &str) -> Option<Capability> {
"rootless-podman" => Some(Capability::RootlessPodman), "rootless-podman" => Some(Capability::RootlessPodman),
"source-filesystem" => Some(Capability::SourceFilesystem), "source-filesystem" => Some(Capability::SourceFilesystem),
"source-git" => Some(Capability::SourceGit), "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), "vfs-artifacts" => Some(Capability::VfsArtifacts),
"wasmtime" => Some(Capability::Wasmtime), "wasmtime" => Some(Capability::Wasmtime),
"windows-command-dev" => Some(Capability::WindowsCommandDev), "windows-command-dev" => Some(Capability::WindowsCommandDev),
@ -4006,6 +4132,54 @@ mod tests {
assert!(!plan.capabilities.arch.is_empty()); 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::<Vec<_>>();
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] #[test]
fn agents_can_select_hosted_with_public_key_identity() { fn agents_can_select_hosted_with_public_key_identity() {
let args = RunArgs { let args = RunArgs {

View file

@ -115,6 +115,7 @@ for (const [name, pattern] of [
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["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\(\)/], ["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\(\)/], ["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 local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/],
["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], ["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\(\)/], ["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", "CLI sends agent workflow actor fields",
/fn add_workflow_actor_fields[\s\S]*actor_agent[\s\S]*agent_public_key_fingerprint/ /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 [ for (const [name, pattern] of [
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],