Skip to main content

harn_cli/commands/
pack.rs

1//! `harn pack <entrypoint>` — build a signed-ready `.harnpack` from a
2//! Harn entrypoint.
3//!
4//! Walks the entrypoint's transitive imports, precompiles every module
5//! into a `.harnbc` artifact, snapshots the provider catalog and
6//! stdlib pin, generates a minimal SBOM, assembles a v2 `WorkflowBundle`
7//! manifest, and emits a deterministic tar.zst archive.
8//!
9//! `harn pack verify <bundle.harnpack>` (#1779) reads a bundle back,
10//! recomputes its canonical hash, verifies the embedded Ed25519
11//! signature (if any), and cross-checks every per-module BLAKE3.
12
13use std::collections::BTreeMap;
14use std::path::{Component, Path, PathBuf};
15use std::process;
16
17use ed25519_dalek::Signer;
18use ed25519_dalek::VerifyingKey;
19use harn_parser::DiagnosticSeverity;
20use harn_vm::bytecode_cache;
21use harn_vm::module_artifact;
22use harn_vm::orchestration::{
23    build_harnpack, load_workflow_bundle_any_version, read_harnpack,
24    verify_workflow_bundle_signature, workflow_bundle_hash, CatchupPolicySpec,
25    ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
26    RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
27    WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
28    WORKFLOW_BUNDLE_SCHEMA_VERSION,
29};
30use harn_vm::Compiler;
31use harn_vm::{AutonomyTier, TrustRecord};
32use serde::{Deserialize, Serialize};
33
34use crate::cli::{PackArgs, PackCommand, PackVerifyArgs};
35use crate::command_error;
36use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput, JsonWarning};
37use crate::parse_source_file;
38use crate::skill_provenance;
39
40/// Stable schema version for the `harn pack --json` envelope. Bump when
41/// [`PackJsonData`] changes shape in a way that agents need to detect.
42pub const PACK_SCHEMA_VERSION: u32 = 2;
43pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
44
45/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
46#[derive(Debug, Clone, Serialize)]
47pub struct PackJsonData {
48    pub bundle_hash: String,
49    pub output_path: PathBuf,
50    pub size_bytes: u64,
51    pub signature: PackSignatureSummary,
52    pub sbom_summary: PackSbomSummary,
53    pub debug_symbol_metadata: PackDebugSymbolMetadata,
54    pub manifest: WorkflowBundle,
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct PackSignatureSummary {
59    pub algorithm: String,
60    pub key_id: Option<String>,
61    pub present: bool,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct PackSbomSummary {
66    pub components: usize,
67    pub stdlib_modules: usize,
68    pub providers: usize,
69    pub tools: usize,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct PackDebugSymbolMetadata {
74    pub harnbc_count: usize,
75    pub total_bytes: u64,
76}
77
78struct PackJsonOutput {
79    data: PackJsonData,
80    warnings: Vec<JsonWarning>,
81}
82
83impl JsonOutput for PackJsonOutput {
84    const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
85    type Data = PackJsonData;
86    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
87        let mut envelope = JsonEnvelope::ok(Self::SCHEMA_VERSION, self.data);
88        envelope.warnings = self.warnings;
89        envelope
90    }
91}
92
93pub fn run(args: PackArgs) {
94    if let Some(command) = args.command {
95        match command {
96            PackCommand::Verify(verify_args) => return run_verify(verify_args),
97        }
98    }
99    let Some(entrypoint) = args.entrypoint.clone() else {
100        command_error("harn pack requires an entrypoint or a subcommand (see `harn pack --help`)");
101    };
102    let build_args = BuildArgs {
103        entrypoint,
104        out: args.out,
105        upgrade: args.upgrade,
106        sign: args.sign,
107        key: args.key,
108        unsigned: args.unsigned,
109        exclude_secrets: args.exclude_secrets,
110        json: args.json,
111    };
112    match build(&build_args) {
113        Ok(outcome) => {
114            if build_args.json {
115                let envelope = PackJsonOutput {
116                    data: outcome.json,
117                    warnings: outcome.warnings,
118                }
119                .into_envelope();
120                println!("{}", to_string_pretty(&envelope));
121            } else {
122                for warning in &outcome.warnings {
123                    eprintln!("warning[{}]: {}", warning.code, warning.message);
124                }
125                println!(
126                    "wrote {} ({} bytes, bundle_hash {})",
127                    outcome.output_path.display(),
128                    outcome.size_bytes,
129                    outcome.bundle_hash
130                );
131            }
132        }
133        Err(err) => {
134            if build_args.json {
135                let envelope: JsonEnvelope<PackJsonData> =
136                    JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
137                println!("{}", to_string_pretty(&envelope));
138                process::exit(1);
139            }
140            command_error(&err.message);
141        }
142    }
143}
144
145/// Programmatic entrypoint used by tests and other CLI command code
146/// that needs the JSON envelope without going through stdout.
147pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
148    let Some(entrypoint) = args.entrypoint.clone() else {
149        return JsonEnvelope::err(
150            PACK_SCHEMA_VERSION,
151            "pack.missing_entrypoint",
152            "harn pack requires an entrypoint or a subcommand".to_string(),
153        );
154    };
155    let build_args = BuildArgs {
156        entrypoint,
157        out: args.out.clone(),
158        upgrade: args.upgrade.clone(),
159        sign: args.sign,
160        key: args.key.clone(),
161        unsigned: args.unsigned,
162        exclude_secrets: args.exclude_secrets,
163        json: args.json,
164    };
165    match build(&build_args) {
166        Ok(outcome) => PackJsonOutput {
167            data: outcome.json,
168            warnings: outcome.warnings,
169        }
170        .into_envelope(),
171        Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
172    }
173}
174
175/// Plain-data input to [`build`]: a flattened copy of [`PackArgs`]
176/// without the subcommand surface. Tests can construct this directly
177/// instead of going through the CLI parser.
178#[derive(Debug, Clone)]
179pub struct BuildArgs {
180    pub entrypoint: PathBuf,
181    pub out: Option<PathBuf>,
182    pub upgrade: Option<PathBuf>,
183    pub sign: bool,
184    pub key: Option<PathBuf>,
185    pub unsigned: bool,
186    pub exclude_secrets: bool,
187    pub json: bool,
188}
189
190pub fn json_schema() -> serde_json::Value {
191    serde_json::json!({
192        "$schema": "https://json-schema.org/draft/2020-12/schema",
193        "title": "harn pack --json",
194        "type": "object",
195        "required": ["schemaVersion", "ok", "data", "warnings"],
196        "properties": {
197            "schemaVersion": { "const": PACK_SCHEMA_VERSION },
198            "ok": { "const": true },
199            "warnings": { "type": "array" },
200            "data": {
201                "type": "object",
202                "required": [
203                    "bundle_hash",
204                    "output_path",
205                    "size_bytes",
206                    "signature",
207                    "sbom_summary",
208                    "debug_symbol_metadata",
209                    "manifest"
210                ],
211                "properties": {
212                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
213                    "output_path": { "type": "string", "minLength": 1 },
214                    "size_bytes": { "type": "integer", "minimum": 1 },
215                    "signature": {
216                        "type": "object",
217                        "required": ["algorithm", "key_id", "present"],
218                        "properties": {
219                            "algorithm": { "const": "ed25519" },
220                            "key_id": { "type": ["string", "null"] },
221                            "present": { "type": "boolean" }
222                        }
223                    },
224                    "sbom_summary": {
225                        "type": "object",
226                        "required": ["components", "stdlib_modules", "providers", "tools"],
227                        "properties": {
228                            "components": { "type": "integer", "minimum": 1 },
229                            "stdlib_modules": { "type": "integer", "minimum": 0 },
230                            "providers": { "type": "integer", "minimum": 0 },
231                            "tools": { "type": "integer", "minimum": 0 }
232                        }
233                    },
234                    "debug_symbol_metadata": {
235                        "type": "object",
236                        "required": ["harnbc_count", "total_bytes"],
237                        "properties": {
238                            "harnbc_count": { "type": "integer", "minimum": 1 },
239                            "total_bytes": { "type": "integer", "minimum": 1 }
240                        }
241                    },
242                    "manifest": { "type": "object" }
243                }
244            }
245        }
246    })
247}
248
249/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
250/// directly via [`run`].
251#[derive(Debug)]
252pub struct PackOutcome {
253    pub bundle_hash: String,
254    pub output_path: PathBuf,
255    pub size_bytes: u64,
256    pub json: PackJsonData,
257    pub warnings: Vec<JsonWarning>,
258}
259
260#[derive(Debug)]
261pub struct PackError {
262    pub code: &'static str,
263    pub message: String,
264}
265
266impl std::fmt::Display for PackError {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        write!(f, "{}: {}", self.code, self.message)
269    }
270}
271
272impl std::error::Error for PackError {}
273
274impl PackError {
275    fn new(code: &'static str, message: impl Into<String>) -> Self {
276        Self {
277            code,
278            message: message.into(),
279        }
280    }
281}
282
283pub fn build(args: &BuildArgs) -> Result<PackOutcome, PackError> {
284    if args.sign && args.unsigned {
285        return Err(PackError::new(
286            "pack.sign_conflict",
287            "--sign and --unsigned cannot be used together",
288        ));
289    }
290    if args.sign && args.key.is_none() {
291        return Err(PackError::new(
292            "pack.sign_missing_key",
293            "--sign requires --key <path>",
294        ));
295    }
296    if !args.sign && args.key.is_some() {
297        return Err(PackError::new(
298            "pack.key_without_sign",
299            "--key requires --sign",
300        ));
301    }
302    if let Some(upgrade) = &args.upgrade {
303        if !upgrade.exists() {
304            return Err(PackError::new(
305                "upgrade.not_found",
306                format!(
307                    "--upgrade source bundle does not exist: {}",
308                    upgrade.display()
309                ),
310            ));
311        }
312    }
313    let entrypoint_input = args.entrypoint.clone();
314    let entrypoint = entrypoint_input
315        .canonicalize()
316        .unwrap_or_else(|_| entrypoint_input.clone());
317    if !entrypoint.exists() {
318        return Err(PackError::new(
319            "entrypoint.not_found",
320            format!("entrypoint does not exist: {}", entrypoint_input.display()),
321        ));
322    }
323    if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
324    {
325        return Err(PackError::new(
326            "entrypoint.invalid",
327            format!(
328                "entrypoint must be a .harn file: {}",
329                entrypoint_input.display()
330            ),
331        ));
332    }
333    if args.exclude_secrets && path_looks_like_secret(&entrypoint) {
334        return Err(PackError::new(
335            "pack.secret_blocked",
336            format!(
337                "entrypoint {} matches a secret-bearing path pattern; \
338                 re-run with --include-secrets to override",
339                entrypoint_input.display()
340            ),
341        ));
342    }
343    let project_root = pack_archive_root(&entrypoint);
344    let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
345        PackError::new(
346            "entrypoint.outside_root",
347            format!(
348                "entrypoint {} could not be relativized against {}",
349                entrypoint.display(),
350                project_root.display()
351            ),
352        )
353    })?;
354
355    let prior = match &args.upgrade {
356        Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
357            PackError::new(
358                "upgrade.read_failed",
359                format!("failed to read --upgrade source {}: {err}", path.display()),
360            )
361        })?),
362        None => None,
363    };
364
365    let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
366    let mut graph_paths = graph.module_paths();
367    // The entrypoint is always present in the graph; ensure deterministic order.
368    graph_paths.sort();
369    let mut module_paths: Vec<PathBuf> = graph_paths
370        .iter()
371        .filter(|path| is_harn_module_path(path))
372        .cloned()
373        .collect();
374    module_paths.sort();
375
376    let mut transitive_modules = Vec::new();
377    let mut contents = Vec::new();
378    let mut sbom_packages = Vec::new();
379    let mut sbom_relationships = Vec::new();
380    let mut warnings = Vec::new();
381    let mut skipped_assets = Vec::new();
382    let mut debug_symbol_metadata = PackDebugSymbolMetadata {
383        harnbc_count: 0,
384        total_bytes: 0,
385    };
386
387    let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
388    let harn_version = bytecode_cache::HARN_VERSION.to_string();
389
390    sbom_packages.push(SBOMPackage {
391        name: "harn-stdlib".to_string(),
392        version: Some(stdlib_version.clone()),
393        package_hash_blake3: None,
394        license: None,
395    });
396
397    for module_path in &module_paths {
398        let module_str = module_path.to_string_lossy().to_string();
399        if module_str.starts_with("<std>/") {
400            let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
401            sbom_packages.push(SBOMPackage {
402                name: format!("std/{stdlib_name}"),
403                version: Some(stdlib_version.clone()),
404                package_hash_blake3: None,
405                license: None,
406            });
407            sbom_relationships.push(SBOMRelationship {
408                from: format!("entrypoint:{}", entrypoint_rel.display()),
409                to: format!("std/{stdlib_name}"),
410                relationship_type: "depends_on".to_string(),
411            });
412            continue;
413        }
414
415        let source = std::fs::read_to_string(module_path).map_err(|err| {
416            PackError::new(
417                "module.read_failed",
418                format!("failed to read {}: {err}", module_path.display()),
419            )
420        })?;
421
422        let (parsed_source, program) = parse_source_file(&module_str);
423        debug_assert_eq!(parsed_source, source);
424        type_check_or_fail(&source, &module_str, &program)?;
425
426        let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
427            PackError::new(
428                "module.compile_failed",
429                format!("compile error in {}: {err}", module_path.display()),
430            )
431        })?;
432
433        let module_artifact_opt =
434            module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
435
436        let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
437        let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
438            .map_err(|err| {
439                PackError::new(
440                    "module.serialize_failed",
441                    format!(
442                        "failed to serialize chunk for {}: {err}",
443                        module_path.display()
444                    ),
445                )
446            })?;
447
448        let module_artifact_bytes = match module_artifact_opt.as_ref() {
449            Some(artifact) => Some(
450                bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
451                    PackError::new(
452                        "module.serialize_failed",
453                        format!(
454                            "failed to serialize module artifact for {}: {err}",
455                            module_path.display()
456                        ),
457                    )
458                })?,
459            ),
460            None => None,
461        };
462
463        let rel = relativize(&project_root, module_path).ok_or_else(|| {
464            PackError::new(
465                "module.outside_root",
466                format!(
467                    "module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
468                    module_path.display(),
469                    project_root.display()
470                ),
471            )
472        })?;
473        let source_archive_path = PathBuf::from("sources").join(&rel);
474        let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
475            .ok_or_else(|| {
476                PackError::new(
477                    "module.invalid_path",
478                    format!("module path has no stem: {}", module_path.display()),
479                )
480            })?;
481        let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
482
483        let source_hash = blake3_hash(source.as_bytes());
484        let harnbc_hash = blake3_hash(&chunk_bytes);
485        debug_symbol_metadata.harnbc_count += 1;
486        debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
487
488        transitive_modules.push(ModuleEntry {
489            path: rel.clone(),
490            source_hash_blake3: source_hash.clone(),
491            harnbc_hash_blake3: harnbc_hash.clone(),
492        });
493
494        contents.push(HarnpackEntry::new(
495            source_archive_path,
496            source.as_bytes().to_vec(),
497        ));
498        contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
499        if let Some(artifact_bytes) = module_artifact_bytes {
500            debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
501            let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
502                .ok_or_else(|| {
503                    PackError::new(
504                        "module.invalid_path",
505                        format!("module path has no stem: {}", module_path.display()),
506                    )
507                })?;
508            let module_archive_path = PathBuf::from("bytecode").join(module_rel);
509            contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
510        }
511
512        if module_path != &entrypoint {
513            sbom_relationships.push(SBOMRelationship {
514                from: format!("entrypoint:{}", entrypoint_rel.display()),
515                to: format!("module:{}", rel.display()),
516                relationship_type: "depends_on".to_string(),
517            });
518        }
519        sbom_packages.push(SBOMPackage {
520            name: format!("module:{}", rel.display()),
521            version: Some(harn_version.clone()),
522            package_hash_blake3: Some(source_hash),
523            license: None,
524        });
525    }
526
527    for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
528        if args.exclude_secrets && path_looks_like_secret(&asset.path) {
529            warnings.push(JsonWarning {
530                code: "pack.asset_skipped_secret".to_string(),
531                message: format!(
532                    "skipped imported asset {} because it matches a secret-bearing path pattern",
533                    asset.rel.display()
534                ),
535            });
536            skipped_assets.push(SkippedAsset {
537                path: asset.rel.clone(),
538                reason: "secret_path".to_string(),
539            });
540            continue;
541        }
542
543        let bytes = std::fs::read(&asset.path).map_err(|err| {
544            PackError::new(
545                "asset.read_failed",
546                format!(
547                    "failed to read imported asset {}: {err}",
548                    asset.path.display()
549                ),
550            )
551        })?;
552        let asset_hash = blake3_hash(&bytes);
553        contents.push(HarnpackEntry::new(
554            PathBuf::from("sources").join(&asset.rel),
555            bytes,
556        ));
557        sbom_packages.push(SBOMPackage {
558            name: format!("asset:{}", asset.rel.display()),
559            version: Some(harn_version.clone()),
560            package_hash_blake3: Some(asset_hash),
561            license: None,
562        });
563        sbom_relationships.push(SBOMRelationship {
564            from: format!("entrypoint:{}", entrypoint_rel.display()),
565            to: format!("asset:{}", asset.rel.display()),
566            relationship_type: "depends_on".to_string(),
567        });
568    }
569
570    if transitive_modules.is_empty() {
571        return Err(PackError::new(
572            "pack.no_modules",
573            format!(
574                "no Harn modules resolved from entrypoint {}",
575                entrypoint.display()
576            ),
577        ));
578    }
579
580    let provider_catalog = harn_vm::provider_catalog::artifact();
581    let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
582        PackError::new(
583            "provider_catalog.failed",
584            format!("failed to serialize provider catalog snapshot: {err}"),
585        )
586    })?;
587    let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
588    sbom_packages.push(SBOMPackage {
589        name: "harn-provider-catalog".to_string(),
590        version: Some(harn_version.clone()),
591        package_hash_blake3: Some(provider_catalog_hash.clone()),
592        license: None,
593    });
594    sbom_relationships.push(SBOMRelationship {
595        from: format!("entrypoint:{}", entrypoint_rel.display()),
596        to: "harn-provider-catalog".to_string(),
597        relationship_type: "depends_on".to_string(),
598    });
599    for provider in &provider_catalog.providers {
600        let provider_name = format!("provider:{}", provider.id);
601        sbom_packages.push(SBOMPackage {
602            name: provider_name.clone(),
603            version: None,
604            package_hash_blake3: None,
605            license: None,
606        });
607        sbom_relationships.push(SBOMRelationship {
608            from: "harn-provider-catalog".to_string(),
609            to: provider_name,
610            relationship_type: "contains".to_string(),
611        });
612    }
613
614    // Tool entries use the same manifest/SBOM path as modules and
615    // providers, keeping the archive representation centralized.
616    let tool_manifest: Vec<ToolEntry> = Vec::new();
617    for tool in &tool_manifest {
618        sbom_packages.push(SBOMPackage {
619            name: format!("tool:{}", tool.name),
620            version: None,
621            package_hash_blake3: tool.schema_hash_blake3.clone(),
622            license: None,
623        });
624        sbom_relationships.push(SBOMRelationship {
625            from: format!("entrypoint:{}", entrypoint_rel.display()),
626            to: format!("tool:{}", tool.name),
627            relationship_type: "depends_on".to_string(),
628        });
629    }
630    let mut bundle = assemble_bundle(
631        &entrypoint_rel,
632        transitive_modules,
633        stdlib_version,
634        harn_version,
635        provider_catalog_hash,
636        tool_manifest,
637        SBOMDoc {
638            format: "spdx-lite".to_string(),
639            version: "2.3".to_string(),
640            packages: sbom_packages,
641            relationships: sbom_relationships,
642        },
643        prior.as_ref(),
644    );
645    if !skipped_assets.is_empty() {
646        bundle.metadata.insert(
647            "skipped_assets".to_string(),
648            serde_json::to_value(&skipped_assets).map_err(|err| {
649                PackError::new(
650                    "pack.metadata_failed",
651                    format!("failed to render skipped asset metadata: {err}"),
652                )
653            })?,
654        );
655    }
656    sort_sbom_doc(&mut bundle.sbom);
657    let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
658        PackError::new(
659            "pack.sbom_failed",
660            format!("failed to render SBOM document: {err}"),
661        )
662    })?;
663    contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
664
665    if args.sign {
666        let key_path = args.key.as_ref().expect("checked above");
667        sign_bundle(&mut bundle, &contents, key_path)?;
668    }
669
670    let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
671        PackError::new(
672            "pack.hash_failed",
673            format!("failed to compute bundle hash: {err}"),
674        )
675    })?;
676    let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
677        PackError::new(
678            "pack.archive_failed",
679            format!("failed to assemble .harnpack archive: {err}"),
680        )
681    })?;
682
683    let output_path = resolve_output_path(&args.out, &entrypoint);
684    if let Some(parent) = output_path.parent() {
685        if !parent.as_os_str().is_empty() {
686            std::fs::create_dir_all(parent).map_err(|err| {
687                PackError::new(
688                    "pack.output_dir_failed",
689                    format!("failed to create output dir {}: {err}", parent.display()),
690                )
691            })?;
692        }
693    }
694    std::fs::write(&output_path, &archive_bytes).map_err(|err| {
695        PackError::new(
696            "pack.write_failed",
697            format!("failed to write {}: {err}", output_path.display()),
698        )
699    })?;
700    let size_bytes = archive_bytes.len() as u64;
701    emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
702
703    Ok(PackOutcome {
704        bundle_hash: bundle_hash.clone(),
705        output_path: output_path.clone(),
706        size_bytes,
707        json: PackJsonData {
708            bundle_hash,
709            output_path,
710            size_bytes,
711            signature: signature_summary(&bundle),
712            sbom_summary: sbom_summary(&bundle),
713            debug_symbol_metadata,
714            manifest: bundle,
715        },
716        warnings,
717    })
718}
719
720fn sign_bundle(
721    bundle: &mut WorkflowBundle,
722    contents: &[HarnpackEntry],
723    key_path: &Path,
724) -> Result<(), PackError> {
725    let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
726        PackError::new(
727            "pack.sign_key_failed",
728            format!("failed to load signing key {}: {err}", key_path.display()),
729        )
730    })?;
731    let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
732        PackError::new(
733            "pack.hash_failed",
734            format!("failed to compute bundle hash before signing: {err}"),
735        )
736    })?;
737    let verifying_key = signing_key.verifying_key();
738    let signature = signing_key.sign(bundle_hash.as_bytes());
739    bundle.signature = Some(Ed25519Signature {
740        key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
741        public_key: hex_encode(&verifying_key.to_bytes()),
742        signature: hex_encode(&signature.to_bytes()),
743        manifest_hash_blake3: bundle_hash,
744        algorithm: "ed25519".to_string(),
745    });
746    Ok(())
747}
748
749fn hex_encode(bytes: &[u8]) -> String {
750    let mut out = String::with_capacity(bytes.len() * 2);
751    for byte in bytes {
752        out.push_str(&format!("{byte:02x}"));
753    }
754    out
755}
756
757fn emit_release_trust_record(
758    project_root: &Path,
759    bundle_hash: &str,
760    harn_version: &str,
761    signed: bool,
762) -> Result<TrustRecord, PackError> {
763    let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
764        PackError::new(
765            "pack.trust_log_failed",
766            format!(
767                "failed to open OpenTrustGraph event log under {}: {err}",
768                project_root.display()
769            ),
770        )
771    })?;
772    let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
773        &log,
774        &harn_vm::TrustQueryFilters::default(),
775    ))
776    .map_err(|err| {
777        PackError::new(
778            "pack.trust_query_failed",
779            format!("failed to query prior OpenTrustGraph records: {err}"),
780        )
781    })?
782    .last()
783    .map(|record| record.record_id.clone());
784    let mut record = TrustRecord::release(
785        std::env::var("USER")
786            .ok()
787            .filter(|value| !value.trim().is_empty())
788            .unwrap_or_else(|| "harn-pack".to_string()),
789        bundle_hash.to_string(),
790        harn_version.to_string(),
791        parent_trust_record_id,
792        format!("harnpack-release-{}", uuid::Uuid::now_v7()),
793        if signed {
794            AutonomyTier::ActAuto
795        } else {
796            AutonomyTier::Suggest
797        },
798    );
799    record
800        .metadata
801        .insert("signed".to_string(), serde_json::json!(signed));
802    futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
803        PackError::new(
804            "pack.trust_record_failed",
805            format!("failed to append OpenTrustGraph release record: {err}"),
806        )
807    })
808}
809
810fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
811    match &bundle.signature {
812        Some(signature) => PackSignatureSummary {
813            algorithm: signature.algorithm.clone(),
814            key_id: signature.key_id.clone(),
815            present: true,
816        },
817        None => PackSignatureSummary {
818            algorithm: "ed25519".to_string(),
819            key_id: None,
820            present: false,
821        },
822    }
823}
824
825fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
826    let stdlib_modules = bundle
827        .sbom
828        .packages
829        .iter()
830        .filter(|package| package.name.starts_with("std/"))
831        .count();
832    let providers = bundle
833        .sbom
834        .packages
835        .iter()
836        .filter(|package| package.name.starts_with("provider:"))
837        .count();
838    PackSbomSummary {
839        components: bundle.sbom.packages.len(),
840        stdlib_modules,
841        providers,
842        tools: bundle.tool_manifest.len(),
843    }
844}
845
846#[derive(Debug)]
847struct ImportedAsset {
848    path: PathBuf,
849    rel: PathBuf,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853struct SkippedAsset {
854    path: PathBuf,
855    reason: String,
856}
857
858fn discover_import_assets(
859    graph: &harn_modules::ModuleGraph,
860    module_paths: &[PathBuf],
861    project_root: &Path,
862) -> Result<Vec<ImportedAsset>, PackError> {
863    let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
864    for module_path in module_paths {
865        if module_path.to_string_lossy().starts_with("<std>/") {
866            continue;
867        }
868        for import in graph.imports_for_module(module_path) {
869            let Some(resolved_path) = import.resolved_path else {
870                continue;
871            };
872            if is_harn_module_path(&resolved_path) {
873                continue;
874            }
875            let canonical = resolved_path
876                .canonicalize()
877                .unwrap_or_else(|_| resolved_path.clone());
878            let rel = relativize(project_root, &canonical).ok_or_else(|| {
879                PackError::new(
880                    "asset.outside_root",
881                    format!(
882                        "imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
883                        canonical.display(),
884                        project_root.display()
885                    ),
886                )
887            })?;
888            assets.entry(canonical.clone()).or_insert(ImportedAsset {
889                path: canonical,
890                rel,
891            });
892        }
893    }
894    Ok(assets.into_values().collect())
895}
896
897fn is_harn_module_path(path: &Path) -> bool {
898    path.to_string_lossy().starts_with("<std>/")
899        || path.extension().and_then(|ext| ext.to_str()) == Some("harn")
900}
901
902fn sort_sbom_doc(sbom: &mut SBOMDoc) {
903    sbom.packages.sort_by(|left, right| {
904        (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
905            &right.name,
906            &right.version,
907            &right.package_hash_blake3,
908        ))
909    });
910    sbom.relationships.sort_by(|left, right| {
911        (&left.from, &left.to, &left.relationship_type).cmp(&(
912            &right.from,
913            &right.to,
914            &right.relationship_type,
915        ))
916    });
917}
918
919fn assemble_bundle(
920    entrypoint_rel: &Path,
921    transitive_modules: Vec<ModuleEntry>,
922    stdlib_version: String,
923    harn_version: String,
924    provider_catalog_hash: String,
925    tool_manifest: Vec<ToolEntry>,
926    sbom: SBOMDoc,
927    prior: Option<&WorkflowBundle>,
928) -> WorkflowBundle {
929    let stem = entrypoint_rel
930        .file_stem()
931        .map(|s| s.to_string_lossy().into_owned())
932        .unwrap_or_else(|| "harnpack".to_string());
933
934    let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
935        id: stem.clone(),
936        name: Some(stem.clone()),
937        version: "0.0.0".to_string(),
938        workflow: degenerate_workflow(&stem),
939        triggers: vec![WorkflowBundleTrigger {
940            id: "manual".to_string(),
941            kind: "manual".to_string(),
942            node_id: Some("entry".to_string()),
943            ..WorkflowBundleTrigger::default()
944        }],
945        policy: WorkflowBundlePolicy {
946            autonomy_tier: "act_with_approval".to_string(),
947            tool_policy: BTreeMap::new(),
948            approval_required: Vec::new(),
949            retry: RetryPolicySpec {
950                max_attempts: 1,
951                backoff: "none".to_string(),
952            },
953            catchup: CatchupPolicySpec {
954                mode: "none".to_string(),
955                max_events: None,
956            },
957        },
958        connectors: Vec::<ConnectorRequirement>::new(),
959        environment: EnvironmentRequirements::default(),
960        receipts: WorkflowBundleReplayMetadata::default(),
961        ..WorkflowBundle::default()
962    });
963
964    bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
965    bundle.entrypoint = entrypoint_rel.to_path_buf();
966    bundle.transitive_modules = transitive_modules;
967    bundle.stdlib_version = stdlib_version;
968    bundle.harn_version = harn_version;
969    bundle.provider_catalog_hash = provider_catalog_hash;
970    bundle.tool_manifest = tool_manifest;
971    bundle.sbom = sbom;
972    bundle.signature = None;
973    bundle
974}
975
976fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
977    use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
978    let mut nodes = BTreeMap::new();
979    nodes.insert(
980        "entry".to_string(),
981        WorkflowNode {
982            id: Some("entry".to_string()),
983            kind: "action".to_string(),
984            task_label: Some(stem.to_string()),
985            ..WorkflowNode::default()
986        },
987    );
988    WorkflowGraph {
989        type_name: "workflow_graph".to_string(),
990        id: format!("{stem}_pack"),
991        name: Some(stem.to_string()),
992        version: 1,
993        entry: "entry".to_string(),
994        nodes,
995        ..WorkflowGraph::default()
996    }
997}
998
999fn type_check_or_fail(
1000    source: &str,
1001    path: &str,
1002    program: &[harn_parser::SNode],
1003) -> Result<(), PackError> {
1004    let mut had_error = false;
1005    let mut messages = String::new();
1006    for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
1007        let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
1008        if matches!(diag.severity, DiagnosticSeverity::Error) {
1009            had_error = true;
1010        }
1011        messages.push_str(&rendered);
1012    }
1013    if had_error {
1014        return Err(PackError::new(
1015            "module.type_error",
1016            format!("type errors in {path}:\n{messages}"),
1017        ));
1018    }
1019    if !messages.is_empty() {
1020        eprint!("{messages}");
1021    }
1022    Ok(())
1023}
1024
1025fn pack_archive_root(entrypoint: &Path) -> PathBuf {
1026    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1027    harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
1028}
1029
1030fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
1031    let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1032    let target_canon = target
1033        .canonicalize()
1034        .unwrap_or_else(|_| target.to_path_buf());
1035    if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
1036        return Some(rel.to_path_buf());
1037    }
1038    None
1039}
1040
1041fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
1042    let stem = rel.file_stem()?.to_string_lossy().into_owned();
1043    if stem.is_empty() {
1044        return None;
1045    }
1046    let parent_components: Vec<Component<'_>> = rel
1047        .parent()
1048        .map(|p| p.components().collect())
1049        .unwrap_or_default();
1050    let mut adjacent = PathBuf::new();
1051    for component in parent_components {
1052        adjacent.push(component.as_os_str());
1053    }
1054    let mut filename = stem;
1055    filename.push('.');
1056    filename.push_str(extension);
1057    adjacent.push(filename);
1058    Some(adjacent)
1059}
1060
1061fn blake3_hash(bytes: &[u8]) -> String {
1062    format!("blake3:{}", blake3::hash(bytes))
1063}
1064
1065fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
1066    if let Some(path) = out {
1067        return path.clone();
1068    }
1069    let stem = entrypoint
1070        .file_stem()
1071        .map(|s| s.to_string_lossy().into_owned())
1072        .unwrap_or_else(|| "bundle".to_string());
1073    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1074    parent.join(format!("{stem}.harnpack"))
1075}
1076
1077/// Heuristic gate for `--exclude-secrets`. Matches `.env`, `.env.*`,
1078/// `*.pem`, `*.key`, `credentials*`, and any path under a `secrets/`
1079/// directory. Kept conservative so false positives don't strand
1080/// legitimate bundles; mirrors common git secret-scanning policies.
1081pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
1082    let lower_name = path
1083        .file_name()
1084        .map(|s| s.to_string_lossy().to_ascii_lowercase())
1085        .unwrap_or_default();
1086    if lower_name == ".env" || lower_name.starts_with(".env.") {
1087        return true;
1088    }
1089    if lower_name.starts_with("credentials") {
1090        return true;
1091    }
1092    if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1093        let ext = ext.to_ascii_lowercase();
1094        if ext == "pem" || ext == "key" {
1095            return true;
1096        }
1097    }
1098    for component in path.components() {
1099        if let Component::Normal(part) = component {
1100            if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
1101                return true;
1102            }
1103        }
1104    }
1105    false
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use std::fs;
1112
1113    fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
1114        BuildArgs {
1115            entrypoint,
1116            out: Some(out),
1117            upgrade: None,
1118            sign: false,
1119            key: None,
1120            unsigned: true,
1121            exclude_secrets: false,
1122            json: true,
1123        }
1124    }
1125
1126    #[test]
1127    fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
1128        let temp = tempfile::tempdir().unwrap();
1129        fs::write(
1130            temp.path().join("harn.toml"),
1131            "[package]\nname = \"pack-root\"\n",
1132        )
1133        .unwrap();
1134        fs::create_dir_all(temp.path().join("scripts")).unwrap();
1135        fs::create_dir_all(temp.path().join("assets")).unwrap();
1136        fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
1137        fs::write(
1138            temp.path().join("scripts/entry.harn"),
1139            "import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
1140        )
1141        .unwrap();
1142
1143        let outcome = build(&build_args(
1144            temp.path().join("scripts/entry.harn"),
1145            temp.path().join("bundle.harnpack"),
1146        ))
1147        .unwrap();
1148
1149        assert_eq!(
1150            outcome.json.manifest.entrypoint,
1151            PathBuf::from("scripts/entry.harn")
1152        );
1153        assert!(outcome
1154            .json
1155            .manifest
1156            .sbom
1157            .packages
1158            .iter()
1159            .any(|package| package.name == "asset:assets/prompt.txt"));
1160    }
1161
1162    #[test]
1163    fn pack_rejects_imported_asset_outside_archive_root() {
1164        let temp = tempfile::tempdir().unwrap();
1165        let root = temp.path().join("root");
1166        let outside = temp.path().join("outside");
1167        fs::create_dir_all(&root).unwrap();
1168        fs::create_dir_all(&outside).unwrap();
1169        fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
1170        fs::write(
1171            root.join("entry.harn"),
1172            "import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
1173        )
1174        .unwrap();
1175
1176        let err = build(&build_args(
1177            root.join("entry.harn"),
1178            root.join("bundle.harnpack"),
1179        ))
1180        .unwrap_err();
1181
1182        assert_eq!(err.code, "asset.outside_root");
1183        assert!(!root.join("bundle.harnpack").exists());
1184    }
1185}
1186
1187// --- `harn pack verify` -----------------------------------------------------
1188
1189/// Stable schema version for the `harn pack verify --json` envelope.
1190/// Bump when [`PackVerifyJsonData`] changes shape in a way agents need
1191/// to detect.
1192pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
1193
1194/// JSON payload emitted under `JsonEnvelope.data` for `harn pack verify`.
1195#[derive(Debug, Clone, Serialize)]
1196pub struct PackVerifyJsonData {
1197    pub bundle: PathBuf,
1198    pub bundle_hash: String,
1199    pub recorded_bundle_hash: Option<String>,
1200    pub signature_present: bool,
1201    pub signature_verified: bool,
1202    pub key_id: Option<String>,
1203    pub schema_version: u32,
1204    pub entrypoint: PathBuf,
1205    pub module_count: usize,
1206    pub content_entry_count: usize,
1207}
1208
1209struct PackVerifyJsonOutput(PackVerifyJsonData);
1210
1211impl JsonOutput for PackVerifyJsonOutput {
1212    const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
1213    type Data = PackVerifyJsonData;
1214    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
1215        JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
1216    }
1217}
1218
1219/// JSON schema for `harn pack verify --json`. Mirrors the runtime
1220/// envelope so agents can validate output before consuming it.
1221pub fn verify_json_schema() -> serde_json::Value {
1222    serde_json::json!({
1223        "$schema": "https://json-schema.org/draft/2020-12/schema",
1224        "title": "harn pack verify --json",
1225        "type": "object",
1226        "required": ["schemaVersion", "ok", "data", "warnings"],
1227        "properties": {
1228            "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
1229            "ok": { "type": "boolean" },
1230            "warnings": { "type": "array" },
1231            "data": {
1232                "type": "object",
1233                "required": [
1234                    "bundle",
1235                    "bundle_hash",
1236                    "signature_present",
1237                    "signature_verified",
1238                    "recorded_bundle_hash",
1239                    "key_id",
1240                    "schema_version",
1241                    "entrypoint",
1242                    "module_count",
1243                    "content_entry_count"
1244                ],
1245                "properties": {
1246                    "bundle": { "type": "string", "minLength": 1 },
1247                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
1248                    "recorded_bundle_hash": { "type": ["string", "null"] },
1249                    "signature_present": { "type": "boolean" },
1250                    "signature_verified": { "type": "boolean" },
1251                    "key_id": { "type": ["string", "null"] },
1252                    "schema_version": { "type": "integer", "minimum": 1 },
1253                    "entrypoint": { "type": "string", "minLength": 1 },
1254                    "module_count": { "type": "integer", "minimum": 1 },
1255                    "content_entry_count": { "type": "integer", "minimum": 1 }
1256                }
1257            }
1258        }
1259    })
1260}
1261
1262/// Dispatcher for [`PackCommand::Verify`]: prints a human-readable
1263/// line or a `JsonEnvelope` and exits non-zero on verification failure.
1264pub fn run_verify(args: PackVerifyArgs) {
1265    match verify(&args) {
1266        Ok(outcome) => {
1267            if args.json {
1268                let envelope = PackVerifyJsonOutput(outcome).into_envelope();
1269                println!("{}", to_string_pretty(&envelope));
1270            } else {
1271                println!(
1272                    "ok {} (bundle_hash {}, signature_verified={})",
1273                    outcome.bundle.display(),
1274                    outcome.bundle_hash,
1275                    outcome.signature_verified
1276                );
1277            }
1278        }
1279        Err(err) => {
1280            if args.json {
1281                let envelope: JsonEnvelope<PackVerifyJsonData> =
1282                    JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
1283                println!("{}", to_string_pretty(&envelope));
1284                process::exit(1);
1285            }
1286            command_error(&err.message);
1287        }
1288    }
1289}
1290
1291/// Programmatic verify entry point used by tests so they can read the
1292/// envelope structurally instead of parsing stdout.
1293pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
1294    match verify(args) {
1295        Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
1296        Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
1297    }
1298}
1299
1300/// Verify the bundle at `args.bundle`:
1301///
1302/// 1. Read the archive (`tar.zst`) and decode the manifest.
1303/// 2. Recompute the canonical bundle hash from manifest + contents.
1304/// 3. If the manifest carries an Ed25519 signature, run the existing
1305///    [`verify_workflow_bundle_signature`] check; refuse unsigned
1306///    bundles unless `--allow-unsigned` was passed.
1307/// 4. Walk each `ModuleEntry` and verify its `source_hash_blake3` /
1308///    `harnbc_hash_blake3` match the in-archive payload.
1309///
1310/// Any mismatch yields a [`PackError`] with a stable structured code
1311/// suitable for JSON consumers.
1312pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
1313    let bytes = std::fs::read(&args.bundle).map_err(|err| {
1314        PackError::new(
1315            "verify.read_failed",
1316            format!("failed to read {}: {err}", args.bundle.display()),
1317        )
1318    })?;
1319    let archive = read_harnpack(&bytes).map_err(|err| {
1320        PackError::new(
1321            "verify.archive_failed",
1322            format!("failed to parse {}: {err}", args.bundle.display()),
1323        )
1324    })?;
1325    let manifest = &archive.manifest;
1326    let contents = &archive.contents;
1327
1328    let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
1329        PackError::new(
1330            "verify.hash_failed",
1331            format!("failed to recompute bundle hash: {err}"),
1332        )
1333    })?;
1334
1335    let trust_policy = args
1336        .trust_policy
1337        .as_deref()
1338        .map(skill_provenance::load_trust_policy)
1339        .transpose()
1340        .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
1341    let signature_present = manifest.signature.is_some();
1342    let mut signature_verified = false;
1343    let mut key_id = None;
1344    if let Some(signature) = manifest.signature.as_ref() {
1345        key_id = signature.key_id.clone();
1346        verify_workflow_bundle_signature(manifest, contents)
1347            .map_err(|err| PackError::new("verify.signature_failed", err.message.clone()))?;
1348        if args.require_trusted_signer {
1349            let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
1350                PackError::new(
1351                    "verify.signature_failed",
1352                    format!("invalid bundle signer: {err}"),
1353                )
1354            })?;
1355            match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
1356                .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
1357            {
1358                skill_provenance::TrustedSignerStatus::Trusted => {}
1359                skill_provenance::TrustedSignerStatus::MissingSigner => {
1360                    return Err(PackError::new(
1361                        "verify.untrusted_signer",
1362                        format!(
1363                            "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
1364                            args.bundle.display(),
1365                            signer_fingerprint
1366                        ),
1367                    ));
1368                }
1369                skill_provenance::TrustedSignerStatus::UntrustedSigner => {
1370                    return Err(PackError::new(
1371                        "verify.untrusted_signer",
1372                        format!(
1373                            "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
1374                            args.bundle.display(),
1375                            signer_fingerprint
1376                        ),
1377                    ));
1378                }
1379            }
1380        }
1381        signature_verified = true;
1382        key_id.get_or_insert(
1383            signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
1384                PackError::new(
1385                    "verify.signature_failed",
1386                    format!("invalid bundle signer: {err}"),
1387                )
1388            })?,
1389        );
1390    } else if args.require_trusted_signer {
1391        return Err(PackError::new(
1392            "verify.untrusted_signer",
1393            format!(
1394                "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
1395                args.bundle.display()
1396            ),
1397        ));
1398    } else if !args.allow_unsigned {
1399        return Err(PackError::new(
1400            "verify.unsigned",
1401            format!(
1402                "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
1403                args.bundle.display()
1404            ),
1405        ));
1406    }
1407
1408    let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
1409    let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
1410    let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
1411    for entry in contents {
1412        archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
1413        if let Ok(rel) = entry.path.strip_prefix("sources") {
1414            source_map.insert(rel.to_path_buf(), entry);
1415        } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
1416            bytecode_map.insert(rel.to_path_buf(), entry);
1417        }
1418    }
1419
1420    for module in &manifest.transitive_modules {
1421        let source_entry = source_map.get(&module.path).ok_or_else(|| {
1422            PackError::new(
1423                "verify.module_missing",
1424                format!(
1425                    "manifest lists module {} but archive has no sources/{} entry",
1426                    module.path.display(),
1427                    module.path.display()
1428                ),
1429            )
1430        })?;
1431        let actual_source = blake3_hash(&source_entry.bytes);
1432        if actual_source != module.source_hash_blake3 {
1433            return Err(PackError::new(
1434                "verify.source_mismatch",
1435                format!(
1436                    "source hash mismatch for {}: manifest {}, archive {}",
1437                    module.path.display(),
1438                    module.source_hash_blake3,
1439                    actual_source
1440                ),
1441            ));
1442        }
1443        let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
1444            .ok_or_else(|| {
1445                PackError::new(
1446                    "verify.module_invalid_path",
1447                    format!("module {} has no stem", module.path.display()),
1448                )
1449            })?;
1450        let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
1451            PackError::new(
1452                "verify.module_missing",
1453                format!(
1454                    "manifest lists bytecode for {} but archive has no bytecode/{} entry",
1455                    module.path.display(),
1456                    chunk_rel.display()
1457                ),
1458            )
1459        })?;
1460        let actual_harnbc = blake3_hash(&chunk_entry.bytes);
1461        if actual_harnbc != module.harnbc_hash_blake3 {
1462            return Err(PackError::new(
1463                "verify.bytecode_mismatch",
1464                format!(
1465                    "bytecode hash mismatch for {}: manifest {}, archive {}",
1466                    module.path.display(),
1467                    module.harnbc_hash_blake3,
1468                    actual_harnbc
1469                ),
1470            ));
1471        }
1472    }
1473
1474    if args.strict {
1475        verify_sbom_package_hashes(manifest, &archive_hashes)?;
1476    }
1477
1478    // Cross-check the recorded signature hash against the recomputed
1479    // canonical hash. `verify_workflow_bundle_signature` already does
1480    // this for signed bundles; for unsigned bundles we report the
1481    // recomputed hash so callers can compare against external
1482    // attestations.
1483    let recorded_bundle_hash = manifest
1484        .signature
1485        .as_ref()
1486        .map(|sig| sig.manifest_hash_blake3.clone());
1487    if let Some(recorded) = &recorded_bundle_hash {
1488        if recorded != &expected_hash {
1489            return Err(PackError::new(
1490                "verify.recorded_hash_mismatch",
1491                format!(
1492                    "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
1493                ),
1494            ));
1495        }
1496    }
1497
1498    Ok(PackVerifyJsonData {
1499        bundle: args.bundle.clone(),
1500        bundle_hash: expected_hash,
1501        recorded_bundle_hash,
1502        signature_present,
1503        signature_verified,
1504        key_id,
1505        schema_version: manifest.schema_version,
1506        entrypoint: manifest.entrypoint.clone(),
1507        module_count: manifest.transitive_modules.len(),
1508        content_entry_count: contents.len(),
1509    })
1510}
1511
1512fn verify_sbom_package_hashes(
1513    manifest: &WorkflowBundle,
1514    archive_hashes: &BTreeMap<PathBuf, String>,
1515) -> Result<(), PackError> {
1516    let module_hashes: BTreeMap<&Path, &str> = manifest
1517        .transitive_modules
1518        .iter()
1519        .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
1520        .collect();
1521
1522    for package in &manifest.sbom.packages {
1523        let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
1524            continue;
1525        };
1526
1527        if let Some(rel) = package.name.strip_prefix("module:") {
1528            let module_path = Path::new(rel);
1529            let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
1530                PackError::new(
1531                    "verify.sbom_mismatch",
1532                    format!(
1533                        "SBOM package {} does not match any manifest transitive module",
1534                        package.name
1535                    ),
1536                )
1537            })?;
1538            if *manifest_hash != expected_hash {
1539                return Err(PackError::new(
1540                    "verify.sbom_mismatch",
1541                    format!(
1542                        "SBOM package {} recorded hash {} but manifest module {} uses {}",
1543                        package.name,
1544                        expected_hash,
1545                        module_path.display(),
1546                        manifest_hash
1547                    ),
1548                ));
1549            }
1550            let source_archive_path = PathBuf::from("sources").join(module_path);
1551            let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
1552                PackError::new(
1553                    "verify.sbom_mismatch",
1554                    format!(
1555                        "SBOM package {} refers to {}, but archive is missing {}",
1556                        package.name,
1557                        module_path.display(),
1558                        source_archive_path.display()
1559                    ),
1560                )
1561            })?;
1562            if archive_hash != expected_hash {
1563                return Err(PackError::new(
1564                    "verify.sbom_mismatch",
1565                    format!(
1566                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
1567                        package.name,
1568                        expected_hash,
1569                        source_archive_path.display(),
1570                        archive_hash
1571                    ),
1572                ));
1573            }
1574            continue;
1575        }
1576
1577        if let Some(rel) = package.name.strip_prefix("asset:") {
1578            let asset_archive_path = PathBuf::from("sources").join(rel);
1579            let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
1580                PackError::new(
1581                    "verify.sbom_mismatch",
1582                    format!(
1583                        "SBOM package {} refers to {}, but archive is missing {}",
1584                        package.name,
1585                        rel,
1586                        asset_archive_path.display()
1587                    ),
1588                )
1589            })?;
1590            if archive_hash != expected_hash {
1591                return Err(PackError::new(
1592                    "verify.sbom_mismatch",
1593                    format!(
1594                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
1595                        package.name,
1596                        expected_hash,
1597                        asset_archive_path.display(),
1598                        archive_hash
1599                    ),
1600                ));
1601            }
1602            continue;
1603        }
1604
1605        let candidate_path = Path::new(&package.name);
1606        let Some(archive_hash) = archive_hashes.get(candidate_path) else {
1607            continue;
1608        };
1609        if archive_hash != expected_hash {
1610            return Err(PackError::new(
1611                "verify.sbom_mismatch",
1612                format!(
1613                    "SBOM package {} recorded hash {} but archive {} hashes to {}",
1614                    package.name,
1615                    expected_hash,
1616                    candidate_path.display(),
1617                    archive_hash
1618                ),
1619            ));
1620        }
1621    }
1622
1623    Ok(())
1624}
1625
1626fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
1627    match signature.key_id.as_deref() {
1628        Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
1629        _ => signer_fingerprint_from_public_key(&signature.public_key),
1630    }
1631}
1632
1633fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
1634    let public_key_bytes = decode_hex_32(public_key_hex)?;
1635    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
1636        format!("workflow bundle signature public_key is invalid Ed25519: {error}")
1637    })?;
1638    Ok(skill_provenance::fingerprint_for_key(&verifying_key))
1639}
1640
1641fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
1642    let trimmed = raw.trim();
1643    if trimmed.len() != 64 {
1644        return Err(format!(
1645            "workflow bundle signature public_key must be 64 hex characters, found {}",
1646            trimmed.len()
1647        ));
1648    }
1649    let mut bytes = [0_u8; 32];
1650    for (idx, slot) in bytes.iter_mut().enumerate() {
1651        let start = idx * 2;
1652        let end = start + 2;
1653        *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
1654            format!(
1655                "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
1656            )
1657        })?;
1658    }
1659    Ok(bytes)
1660}