diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 22f5ea7..41f3e4e 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "abeb19529fac4053ded85c73612264032cc79c08", - "release_name": "dryrun-abeb19529fac", + "source_commit": "cab9305958abb806fe12c27b3d7df0905c5d8e8d", + "release_name": "dryrun-cab9305958ab", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index a13751c..0fc7b9d 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1900,12 +1900,14 @@ fn artifact_download_report(args: ArtifactDownloadArgs) -> Result { "now_epoch_seconds": 0, }))?; let download_session = artifact_download_session_summary(&response); + let grant_disclosures = artifact_download_grant_disclosures(&response); return Ok(json!({ "command": "artifact download", "coordinator": coordinator, "artifact": args.artifact, "max_bytes": args.max_bytes, "download_session": download_session, + "grant_disclosures": grant_disclosures, "response": response, "coordinator_session_requests": session.requests(), })); @@ -1920,6 +1922,7 @@ fn artifact_download_report(args: ArtifactDownloadArgs) -> Result { "link_issued": false, "explicit_user_action_required": true, }, + "grant_disclosures": [], })) } @@ -1949,6 +1952,10 @@ fn artifact_export_report(args: ArtifactExportArgs) -> Result { }) }; apply_local_export_summary(&mut export_plan, &local_export); + let grant_disclosures = local_export + .get("grant_disclosures") + .cloned() + .unwrap_or_else(|| json!([])); return Ok(json!({ "command": "artifact export", "coordinator": coordinator, @@ -1957,6 +1964,7 @@ fn artifact_export_report(args: ArtifactExportArgs) -> Result { "receiver_node": args.receiver_node, "export_plan": export_plan, "local_export": local_export, + "grant_disclosures": grant_disclosures, "response": response, "coordinator_session_requests": session.requests(), })); @@ -1973,6 +1981,7 @@ fn artifact_export_report(args: ArtifactExportArgs) -> Result { "local_bytes_written_by_cli": false, "default_durable_store_assumed": false, }, + "grant_disclosures": [], })) } @@ -2003,10 +2012,14 @@ fn artifact_export_local_write_followup( "local_path": &args.to, "local_bytes_written_by_cli": false, "content_bytes_available": false, + "download_session": artifact_download_session_summary(&link_response), + "grant_disclosures": [], "link_response": link_response, })); } + let download_session = artifact_download_session_summary(&link_response); + let grant_disclosures = artifact_download_grant_disclosures(&link_response); let token_digest = link_response .pointer("/link/scoped_token_digest") .cloned() @@ -2030,6 +2043,8 @@ fn artifact_export_local_write_followup( "local_path": &args.to, "local_bytes_written_by_cli": false, "content_bytes_available": false, + "download_session": download_session, + "grant_disclosures": grant_disclosures, "stream": artifact_stream_summary(&stream_response), })); } @@ -2044,6 +2059,8 @@ fn artifact_export_local_write_followup( "local_path": &args.to, "local_bytes_written_by_cli": false, "content_bytes_available": false, + "download_session": download_session, + "grant_disclosures": grant_disclosures, "stream": artifact_stream_summary(&stream_response), })); }; @@ -2065,6 +2082,8 @@ fn artifact_export_local_write_followup( "bytes_written": bytes.len(), "content_bytes_available": true, "content_source": stream_response.get("content_source").cloned().unwrap_or(Value::Null), + "download_session": download_session, + "grant_disclosures": grant_disclosures, "stream": artifact_stream_summary(&stream_response), })) } @@ -4245,11 +4264,47 @@ fn artifact_download_session_summary(response: &Value) -> Value { "token_material_returned": false, "scoped_token_digest_present": link.get("scoped_token_digest").is_some(), "policy_context_digest_present": link.get("policy_context_digest").is_some(), + "authorization_required": true, + "short_lived": link.get("expires_at_epoch_seconds").is_some(), + "guessable_public_url": false, "cross_tenant_usable": false, + "unauthorized_project_usable": false, "default_durable_store_assumed": false, }) } +fn artifact_download_grant_disclosures(response: &Value) -> Value { + if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") { + return json!([]); + } + + let link = response.get("link").unwrap_or(&Value::Null); + json!([{ + "grant": "artifact_download", + "description": "download scoped artifact bytes to the requesting machine", + "risk": "authorized artifact bytes leave the retaining node or explicit storage through an explicit download/export operation", + "coordinator_policy_limited": true, + "authorization_required": true, + "explicit_user_action_required": true, + "tenant": link.get("tenant").cloned().unwrap_or(Value::Null), + "project": link.get("project").cloned().unwrap_or(Value::Null), + "process": link.get("process").cloned().unwrap_or(Value::Null), + "artifact": link.get("artifact").cloned().unwrap_or(Value::Null), + "actor": link.get("actor").cloned().unwrap_or(Value::Null), + "source": link.get("source").cloned().unwrap_or(Value::Null), + "max_bytes": link.get("max_bytes").cloned().unwrap_or(Value::Null), + "expires_at_epoch_seconds": link.get("expires_at_epoch_seconds").cloned().unwrap_or(Value::Null), + "short_lived": link.get("expires_at_epoch_seconds").is_some(), + "scoped_token_digest_present": link.get("scoped_token_digest").is_some(), + "token_material_returned": false, + "guessable_public_url": false, + "cross_tenant_reuse_allowed": false, + "unauthorized_project_reuse_allowed": false, + "default_durable_store_assumed": false, + "private_website_required": false, + }]) +} + fn artifact_export_plan_summary(response: &Value, to: &Path) -> Value { if response.get("type").and_then(Value::as_str) != Some("artifact_export_plan") { return json!({ @@ -8393,11 +8448,49 @@ mod tests { download["download_session"]["scoped_token_digest_present"], true ); + assert_eq!(download["download_session"]["authorization_required"], true); + assert_eq!(download["download_session"]["short_lived"], true); + assert_eq!(download["download_session"]["guessable_public_url"], false); assert_eq!(download["download_session"]["cross_tenant_usable"], false); + assert_eq!( + download["download_session"]["unauthorized_project_usable"], + false + ); assert_eq!( download["download_session"]["default_durable_store_assumed"], false ); + assert_eq!( + download["grant_disclosures"][0]["grant"], + "artifact_download" + ); + assert_eq!( + download["grant_disclosures"][0]["coordinator_policy_limited"], + true + ); + assert_eq!( + download["grant_disclosures"][0]["scoped_token_digest_present"], + true + ); + assert_eq!( + download["grant_disclosures"][0]["token_material_returned"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["guessable_public_url"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["cross_tenant_reuse_allowed"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["unauthorized_project_reuse_allowed"], + false + ); + let rendered_download = human_report(&download); + assert!(rendered_download.contains("grant artifact_download")); + assert!(rendered_download.contains("policy-limited")); assert_eq!(export["export_plan"]["status"], "transfer_plan_created"); assert_eq!(export["export_plan"]["source_node"], "node-a"); @@ -8418,6 +8511,19 @@ mod tests { export["local_export"]["stream"]["content_material_returned_in_report"], false ); + assert_eq!( + export["local_export"]["download_session"]["scoped_token_digest_present"], + true + ); + assert_eq!( + export["local_export"]["download_session"]["guessable_public_url"], + false + ); + assert_eq!(export["grant_disclosures"][0]["grant"], "artifact_download"); + assert_eq!( + export["grant_disclosures"][0]["cross_tenant_reuse_allowed"], + false + ); assert_eq!( std::fs::read(&export_path).unwrap().as_slice(), b"app-bytes" diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 9b237ed..9b5b077 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -92,6 +92,11 @@ expect( "artifact export explicit local byte write", /artifact export --to ` writes bytes[\s\S]*explicit bounded download stream[\s\S]*complete staged content is available/ ); +expect( + criteria, + "artifact download grant disclosure criteria", + /artifact download ` creates a secure[\s\S]*explicit artifact-download grant disclosure[\s\S]*Download links or sessions are not guessable public URLs[\s\S]*guessable_public_url: false[\s\S]*cross-tenant no-reuse[\s\S]*unauthorized-project no-reuse[\s\S]*Every command that grants[\s\S]*artifact download ability[\s\S]*grant_disclosures/ +); expect( criteria, "locality failure safe guidance", @@ -351,6 +356,11 @@ expect( "CLI exposes node attach grant disclosures", /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ ); +expect( + cli, + "CLI exposes artifact download grant disclosures", + /fn artifact_download_grant_disclosures[\s\S]*"grant": "artifact_download"[\s\S]*"coordinator_policy_limited": true[\s\S]*"authorization_required": true[\s\S]*"guessable_public_url": false[\s\S]*"cross_tenant_reuse_allowed": false[\s\S]*"unauthorized_project_reuse_allowed": false/ +); expect( cli, "CLI exposes normalized node enrollment grants", @@ -471,6 +481,11 @@ expect( "CLI artifact export keeps content out of reports", /fn artifact_stream_summary[\s\S]*content_material_returned_in_report[\s\S]*false/ ); +expect( + cli, + "CLI artifact export carries download grant disclosure", + /fn artifact_export_local_write_followup[\s\S]*artifact_download_grant_disclosures[\s\S]*"grant_disclosures": grant_disclosures/ +); expect( cli, "CLI exposes admin bootstrap self-hosted path",