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

@ -618,12 +618,16 @@ struct NodeAttachPlan {
node: String,
coordinator: Option<String>,
capabilities: NodeCapabilities,
grant_disclosures: Vec<CapabilityGrantDisclosure>,
enrollment: Option<NodeEnrollmentPlan>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
struct NodeAttachReport {
command: String,
node: String,
plan: NodeAttachPlan,
grant_disclosures: Vec<CapabilityGrantDisclosure>,
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<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> {
let coordinator = args
.coordinator
@ -3831,6 +3949,9 @@ fn execute_node_attach(args: AttachArgs) -> Result<NodeAttachReport> {
}))?;
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<Capability> {
"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::<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]
fn agents_can_select_hosted_with_public_key_identity() {
let args = RunArgs {