Disclose node attach capability grants
This commit is contained in:
parent
c514ab7f81
commit
fe6c2d5042
3 changed files with 187 additions and 2 deletions
|
|
@ -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/**",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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/],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue