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 harn_parser::DiagnosticSeverity;
19use harn_vm::bytecode_cache;
20use harn_vm::module_artifact;
21use harn_vm::orchestration::{
22    build_harnpack, load_workflow_bundle_any_version, workflow_bundle_hash, CatchupPolicySpec,
23    ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
24    RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
25    WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
26    WORKFLOW_BUNDLE_SCHEMA_VERSION,
27};
28use harn_vm::{AutonomyTier, TrustRecord};
29use serde::{Deserialize, Serialize};
30
31use crate::cli::{PackArgs, PackCommand};
32use crate::command_error;
33use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput, JsonWarning};
34use crate::parse_source_file;
35use crate::skill_provenance;
36
37/// Stable schema version for the `harn pack --json` envelope. Bump when
38/// [`PackJsonData`] changes shape in a way that agents need to detect.
39pub const PACK_SCHEMA_VERSION: u32 = 2;
40pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
41const DEFAULT_PACK_FILE_MODE: u32 = 0o644;
42
43/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
44#[derive(Debug, Clone, Serialize)]
45pub struct PackJsonData {
46    pub bundle_hash: String,
47    pub output_path: PathBuf,
48    pub size_bytes: u64,
49    pub signature: PackSignatureSummary,
50    pub sbom_summary: PackSbomSummary,
51    pub debug_symbol_metadata: PackDebugSymbolMetadata,
52    pub manifest: WorkflowBundle,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct PackSignatureSummary {
57    pub algorithm: String,
58    pub key_id: Option<String>,
59    pub present: bool,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct PackSbomSummary {
64    pub components: usize,
65    pub stdlib_modules: usize,
66    pub providers: usize,
67    pub tools: usize,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct PackDebugSymbolMetadata {
72    pub harnbc_count: usize,
73    pub total_bytes: u64,
74}
75
76struct PackJsonOutput {
77    data: PackJsonData,
78    warnings: Vec<JsonWarning>,
79}
80
81fn logical_bundle_path(path: &Path) -> String {
82    path.components()
83        .map(|component| component.as_os_str().to_string_lossy())
84        .collect::<Vec<_>>()
85        .join("/")
86}
87
88impl JsonOutput for PackJsonOutput {
89    const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
90    type Data = PackJsonData;
91    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
92        let mut envelope = JsonEnvelope::ok(Self::SCHEMA_VERSION, self.data);
93        envelope.warnings = self.warnings;
94        envelope
95    }
96}
97
98pub fn run(args: PackArgs) {
99    if let Some(command) = args.command {
100        match command {
101            PackCommand::Unpack(unpack_args) => return run_unpack(unpack_args),
102            PackCommand::Repack(repack_args) => return run_repack(repack_args),
103            PackCommand::Verify(verify_args) => return run_verify(verify_args),
104        }
105    }
106    let Some(entrypoint) = args.entrypoint.clone() else {
107        command_error("harn pack requires an entrypoint or a subcommand (see `harn pack --help`)");
108    };
109    let build_args = BuildArgs {
110        entrypoint,
111        out: args.out,
112        upgrade: args.upgrade,
113        sign: args.sign,
114        key: args.key,
115        unsigned: args.unsigned,
116        exclude_secrets: args.exclude_secrets,
117        json: args.json,
118    };
119    match build(&build_args) {
120        Ok(outcome) => {
121            if build_args.json {
122                let envelope = PackJsonOutput {
123                    data: outcome.json,
124                    warnings: outcome.warnings,
125                }
126                .into_envelope();
127                println!("{}", to_string_pretty(&envelope));
128            } else {
129                for warning in &outcome.warnings {
130                    eprintln!("warning[{}]: {}", warning.code, warning.message);
131                }
132                println!(
133                    "wrote {} ({} bytes, bundle_hash {})",
134                    outcome.output_path.display(),
135                    outcome.size_bytes,
136                    outcome.bundle_hash
137                );
138            }
139        }
140        Err(err) => {
141            if build_args.json {
142                let envelope: JsonEnvelope<PackJsonData> =
143                    JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
144                println!("{}", to_string_pretty(&envelope));
145                process::exit(1);
146            }
147            command_error(&err.message);
148        }
149    }
150}
151
152/// Programmatic entrypoint used by tests and other CLI command code
153/// that needs the JSON envelope without going through stdout.
154pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
155    let Some(entrypoint) = args.entrypoint.clone() else {
156        return JsonEnvelope::err(
157            PACK_SCHEMA_VERSION,
158            "pack.missing_entrypoint",
159            "harn pack requires an entrypoint or a subcommand".to_string(),
160        );
161    };
162    let build_args = BuildArgs {
163        entrypoint,
164        out: args.out.clone(),
165        upgrade: args.upgrade.clone(),
166        sign: args.sign,
167        key: args.key.clone(),
168        unsigned: args.unsigned,
169        exclude_secrets: args.exclude_secrets,
170        json: args.json,
171    };
172    match build(&build_args) {
173        Ok(outcome) => PackJsonOutput {
174            data: outcome.json,
175            warnings: outcome.warnings,
176        }
177        .into_envelope(),
178        Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
179    }
180}
181
182/// Plain-data input to [`build`]: a flattened copy of [`PackArgs`]
183/// without the subcommand surface. Tests can construct this directly
184/// instead of going through the CLI parser.
185#[derive(Debug, Clone)]
186pub struct BuildArgs {
187    pub entrypoint: PathBuf,
188    pub out: Option<PathBuf>,
189    pub upgrade: Option<PathBuf>,
190    pub sign: bool,
191    pub key: Option<PathBuf>,
192    pub unsigned: bool,
193    pub exclude_secrets: bool,
194    pub json: bool,
195}
196
197pub fn json_schema() -> serde_json::Value {
198    serde_json::json!({
199        "$schema": "https://json-schema.org/draft/2020-12/schema",
200        "title": "harn pack --json",
201        "type": "object",
202        "required": ["schemaVersion", "ok", "data", "warnings"],
203        "properties": {
204            "schemaVersion": { "const": PACK_SCHEMA_VERSION },
205            "ok": { "const": true },
206            "warnings": { "type": "array" },
207            "data": {
208                "type": "object",
209                "required": [
210                    "bundle_hash",
211                    "output_path",
212                    "size_bytes",
213                    "signature",
214                    "sbom_summary",
215                    "debug_symbol_metadata",
216                    "manifest"
217                ],
218                "properties": {
219                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
220                    "output_path": { "type": "string", "minLength": 1 },
221                    "size_bytes": { "type": "integer", "minimum": 1 },
222                    "signature": {
223                        "type": "object",
224                        "required": ["algorithm", "key_id", "present"],
225                        "properties": {
226                            "algorithm": { "const": "ed25519" },
227                            "key_id": { "type": ["string", "null"] },
228                            "present": { "type": "boolean" }
229                        }
230                    },
231                    "sbom_summary": {
232                        "type": "object",
233                        "required": ["components", "stdlib_modules", "providers", "tools"],
234                        "properties": {
235                            "components": { "type": "integer", "minimum": 1 },
236                            "stdlib_modules": { "type": "integer", "minimum": 0 },
237                            "providers": { "type": "integer", "minimum": 0 },
238                            "tools": { "type": "integer", "minimum": 0 }
239                        }
240                    },
241                    "debug_symbol_metadata": {
242                        "type": "object",
243                        "required": ["harnbc_count", "total_bytes"],
244                        "properties": {
245                            "harnbc_count": { "type": "integer", "minimum": 1 },
246                            "total_bytes": { "type": "integer", "minimum": 1 }
247                        }
248                    },
249                    "manifest": { "type": "object" }
250                }
251            }
252        }
253    })
254}
255
256/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
257/// directly via [`run`].
258#[derive(Debug)]
259pub struct PackOutcome {
260    pub bundle_hash: String,
261    pub output_path: PathBuf,
262    pub size_bytes: u64,
263    pub json: PackJsonData,
264    pub warnings: Vec<JsonWarning>,
265}
266
267#[derive(Debug)]
268pub struct PackError {
269    pub code: &'static str,
270    pub message: String,
271}
272
273impl std::fmt::Display for PackError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        write!(f, "{}: {}", self.code, self.message)
276    }
277}
278
279impl std::error::Error for PackError {}
280
281impl PackError {
282    fn new(code: &'static str, message: impl Into<String>) -> Self {
283        Self {
284            code,
285            message: message.into(),
286        }
287    }
288}
289
290mod archive;
291
292pub use archive::{
293    repack, run_repack, run_unpack, run_verify, unpack, verify, verify_json_schema,
294    verify_to_envelope, PackRepackOutcome, PackUnpackOutcome, PackVerifyJsonData,
295    PACK_VERIFY_SCHEMA_VERSION,
296};
297
298pub fn build(args: &BuildArgs) -> Result<PackOutcome, PackError> {
299    if args.sign && args.unsigned {
300        return Err(PackError::new(
301            "pack.sign_conflict",
302            "--sign and --unsigned cannot be used together",
303        ));
304    }
305    if args.sign && args.key.is_none() {
306        return Err(PackError::new(
307            "pack.sign_missing_key",
308            "--sign requires --key <path>",
309        ));
310    }
311    if !args.sign && args.key.is_some() {
312        return Err(PackError::new(
313            "pack.key_without_sign",
314            "--key requires --sign",
315        ));
316    }
317    if let Some(upgrade) = &args.upgrade {
318        if !upgrade.exists() {
319            return Err(PackError::new(
320                "upgrade.not_found",
321                format!(
322                    "--upgrade source bundle does not exist: {}",
323                    upgrade.display()
324                ),
325            ));
326        }
327    }
328    let entrypoint_input = args.entrypoint.clone();
329    let entrypoint = entrypoint_input
330        .canonicalize()
331        .unwrap_or_else(|_| entrypoint_input.clone());
332    if !entrypoint.exists() {
333        return Err(PackError::new(
334            "entrypoint.not_found",
335            format!("entrypoint does not exist: {}", entrypoint_input.display()),
336        ));
337    }
338    if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
339    {
340        return Err(PackError::new(
341            "entrypoint.invalid",
342            format!(
343                "entrypoint must be a .harn file: {}",
344                entrypoint_input.display()
345            ),
346        ));
347    }
348    if args.exclude_secrets && path_looks_like_secret(&entrypoint) {
349        return Err(PackError::new(
350            "pack.secret_blocked",
351            format!(
352                "entrypoint {} matches a secret-bearing path pattern; \
353                 re-run with --include-secrets to override",
354                entrypoint_input.display()
355            ),
356        ));
357    }
358    let project_root = pack_archive_root(&entrypoint);
359    let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
360        PackError::new(
361            "entrypoint.outside_root",
362            format!(
363                "entrypoint {} could not be relativized against {}",
364                entrypoint.display(),
365                project_root.display()
366            ),
367        )
368    })?;
369    let entrypoint_id = logical_bundle_path(&entrypoint_rel);
370
371    let prior = match &args.upgrade {
372        Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
373            PackError::new(
374                "upgrade.read_failed",
375                format!("failed to read --upgrade source {}: {err}", path.display()),
376            )
377        })?),
378        None => None,
379    };
380
381    let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
382    let mut graph_paths = graph.module_paths();
383    // The entrypoint is always present in the graph; ensure deterministic order.
384    graph_paths.sort();
385    let mut module_paths: Vec<PathBuf> = graph_paths
386        .iter()
387        .filter(|path| is_harn_module_path(path))
388        .cloned()
389        .collect();
390    module_paths.sort();
391
392    let mut transitive_modules = Vec::new();
393    let mut contents = Vec::new();
394    let mut sbom_packages = Vec::new();
395    let mut sbom_relationships = Vec::new();
396    let mut warnings = Vec::new();
397    let mut skipped_assets = Vec::new();
398    let mut debug_symbol_metadata = PackDebugSymbolMetadata {
399        harnbc_count: 0,
400        total_bytes: 0,
401    };
402
403    let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
404    let harn_version = bytecode_cache::HARN_VERSION.to_string();
405
406    sbom_packages.push(SBOMPackage {
407        name: "harn-stdlib".to_string(),
408        version: Some(stdlib_version.clone()),
409        package_hash_blake3: None,
410        license: None,
411    });
412
413    for module_path in &module_paths {
414        let module_str = module_path.to_string_lossy().to_string();
415        if module_str.starts_with("<std>/") {
416            let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
417            sbom_packages.push(SBOMPackage {
418                name: format!("std/{stdlib_name}"),
419                version: Some(stdlib_version.clone()),
420                package_hash_blake3: None,
421                license: None,
422            });
423            sbom_relationships.push(SBOMRelationship {
424                from: format!("entrypoint:{entrypoint_id}"),
425                to: format!("std/{stdlib_name}"),
426                relationship_type: "depends_on".to_string(),
427            });
428            continue;
429        }
430
431        let source = std::fs::read_to_string(module_path).map_err(|err| {
432            PackError::new(
433                "module.read_failed",
434                format!("failed to read {}: {err}", module_path.display()),
435            )
436        })?;
437
438        let (parsed_source, program) = parse_source_file(&module_str);
439        debug_assert_eq!(parsed_source, source);
440        type_check_or_fail(&source, &module_str, &program)?;
441
442        let imported_enum_candidates =
443            crate::imported_enum_candidates_for_source(module_path, &source);
444        let entry_chunk =
445            crate::compiler_with_imported_enum_candidates(imported_enum_candidates.iter().cloned())
446                .compile(&program)
447                .map_err(|err| {
448                    PackError::new(
449                        "module.compile_failed",
450                        format!("compile error in {}: {err}", module_path.display()),
451                    )
452                })?;
453
454        let module_artifact_opt =
455            module_artifact::compile_module_artifact_from_source_with_imported_enums(
456                module_path,
457                &source,
458                imported_enum_candidates,
459            )
460            .ok();
461
462        let entry_cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
463        let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&entry_cache_key, &entry_chunk)
464            .map_err(|err| {
465                PackError::new(
466                    "module.serialize_failed",
467                    format!(
468                        "failed to serialize chunk for {}: {err}",
469                        module_path.display()
470                    ),
471                )
472            })?;
473
474        let module_artifact_bytes = module_artifact_opt
475            .as_ref()
476            .map(|artifact| {
477                let module_cache_key = bytecode_cache::CacheKey::from_module_source(
478                    &harn_vm::module_source::ModuleSource::from_text(source.as_str()),
479                );
480                bytecode_cache::serialize_module_artifact(&module_cache_key, artifact).map_err(
481                    |err| {
482                        PackError::new(
483                            "module.serialize_failed",
484                            format!(
485                                "failed to serialize module artifact for {}: {err}",
486                                module_path.display()
487                            ),
488                        )
489                    },
490                )
491            })
492            .transpose()?;
493
494        let rel = relativize(&project_root, module_path).ok_or_else(|| {
495            PackError::new(
496                "module.outside_root",
497                format!(
498                    "module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
499                    module_path.display(),
500                    project_root.display()
501                ),
502            )
503        })?;
504        let source_archive_path = PathBuf::from("sources").join(&rel);
505        let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
506            .ok_or_else(|| {
507                PackError::new(
508                    "module.invalid_path",
509                    format!("module path has no stem: {}", module_path.display()),
510                )
511            })?;
512        let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
513
514        let source_hash = blake3_hash(source.as_bytes());
515        let harnbc_hash = blake3_hash(&chunk_bytes);
516        debug_symbol_metadata.harnbc_count += 1;
517        debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
518
519        transitive_modules.push(ModuleEntry {
520            path: rel.clone(),
521            source_hash_blake3: source_hash.clone(),
522            harnbc_hash_blake3: harnbc_hash.clone(),
523        });
524
525        contents.push(HarnpackEntry::new(
526            source_archive_path,
527            source.as_bytes().to_vec(),
528        ));
529        contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
530        if let Some(artifact_bytes) = module_artifact_bytes {
531            debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
532            let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
533                .ok_or_else(|| {
534                    PackError::new(
535                        "module.invalid_path",
536                        format!("module path has no stem: {}", module_path.display()),
537                    )
538                })?;
539            let module_archive_path = PathBuf::from("bytecode").join(module_rel);
540            contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
541        }
542
543        let module_id = logical_bundle_path(&rel);
544        if module_path != &entrypoint {
545            sbom_relationships.push(SBOMRelationship {
546                from: format!("entrypoint:{entrypoint_id}"),
547                to: format!("module:{module_id}"),
548                relationship_type: "depends_on".to_string(),
549            });
550        }
551        sbom_packages.push(SBOMPackage {
552            name: format!("module:{module_id}"),
553            version: Some(harn_version.clone()),
554            package_hash_blake3: Some(source_hash),
555            license: None,
556        });
557    }
558
559    for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
560        if args.exclude_secrets && path_looks_like_secret(&asset.path) {
561            warnings.push(JsonWarning {
562                code: "pack.asset_skipped_secret".to_string(),
563                message: format!(
564                    "skipped imported asset {} because it matches a secret-bearing path pattern",
565                    asset.rel.display()
566                ),
567            });
568            skipped_assets.push(SkippedAsset {
569                path: logical_bundle_path(&asset.rel),
570                reason: "secret_path".to_string(),
571            });
572            continue;
573        }
574
575        let bytes = std::fs::read(&asset.path).map_err(|err| {
576            PackError::new(
577                "asset.read_failed",
578                format!(
579                    "failed to read imported asset {}: {err}",
580                    asset.path.display()
581                ),
582            )
583        })?;
584        let asset_hash = blake3_hash(&bytes);
585        let asset_id = logical_bundle_path(&asset.rel);
586        contents.push(HarnpackEntry::new(
587            PathBuf::from("sources").join(&asset.rel),
588            bytes,
589        ));
590        sbom_packages.push(SBOMPackage {
591            name: format!("asset:{asset_id}"),
592            version: Some(harn_version.clone()),
593            package_hash_blake3: Some(asset_hash),
594            license: None,
595        });
596        sbom_relationships.push(SBOMRelationship {
597            from: format!("entrypoint:{entrypoint_id}"),
598            to: format!("asset:{asset_id}"),
599            relationship_type: "depends_on".to_string(),
600        });
601    }
602
603    if transitive_modules.is_empty() {
604        return Err(PackError::new(
605            "pack.no_modules",
606            format!(
607                "no Harn modules resolved from entrypoint {}",
608                entrypoint.display()
609            ),
610        ));
611    }
612
613    let provider_catalog = harn_vm::provider_catalog::artifact();
614    let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
615        PackError::new(
616            "provider_catalog.failed",
617            format!("failed to serialize provider catalog snapshot: {err}"),
618        )
619    })?;
620    let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
621    sbom_packages.push(SBOMPackage {
622        name: "harn-provider-catalog".to_string(),
623        version: Some(harn_version.clone()),
624        package_hash_blake3: Some(provider_catalog_hash.clone()),
625        license: None,
626    });
627    sbom_relationships.push(SBOMRelationship {
628        from: format!("entrypoint:{entrypoint_id}"),
629        to: "harn-provider-catalog".to_string(),
630        relationship_type: "depends_on".to_string(),
631    });
632    for provider in &provider_catalog.providers {
633        let provider_name = format!("provider:{}", provider.id);
634        sbom_packages.push(SBOMPackage {
635            name: provider_name.clone(),
636            version: None,
637            package_hash_blake3: None,
638            license: None,
639        });
640        sbom_relationships.push(SBOMRelationship {
641            from: "harn-provider-catalog".to_string(),
642            to: provider_name,
643            relationship_type: "contains".to_string(),
644        });
645    }
646
647    // Tool entries use the same manifest/SBOM path as modules and
648    // providers, keeping the archive representation centralized.
649    let tool_manifest: Vec<ToolEntry> = Vec::new();
650    for tool in &tool_manifest {
651        sbom_packages.push(SBOMPackage {
652            name: format!("tool:{}", tool.name),
653            version: None,
654            package_hash_blake3: tool.schema_hash_blake3.clone(),
655            license: None,
656        });
657        sbom_relationships.push(SBOMRelationship {
658            from: format!("entrypoint:{entrypoint_id}"),
659            to: format!("tool:{}", tool.name),
660            relationship_type: "depends_on".to_string(),
661        });
662    }
663    let mut bundle = assemble_bundle(
664        &entrypoint_rel,
665        transitive_modules,
666        stdlib_version,
667        harn_version,
668        provider_catalog_hash,
669        tool_manifest,
670        SBOMDoc {
671            format: "spdx-lite".to_string(),
672            version: "2.3".to_string(),
673            packages: sbom_packages,
674            relationships: sbom_relationships,
675        },
676        prior.as_ref(),
677    );
678    if !skipped_assets.is_empty() {
679        bundle.metadata.insert(
680            "skipped_assets".to_string(),
681            serde_json::to_value(&skipped_assets).map_err(|err| {
682                PackError::new(
683                    "pack.metadata_failed",
684                    format!("failed to render skipped asset metadata: {err}"),
685                )
686            })?,
687        );
688    }
689    // Carry host-surface extension data — the `[[contributes]]` block plus
690    // package identity/permissions — into the signed bundle's metadata so a
691    // host (e.g. an IDE) can discover and gate contributions from the verified
692    // artifact alone, with no separate descriptor. Harn stays agnostic about
693    // the kind-specific payload; it only ferries it. See `docs` and the
694    // `ContributionEntry` schema.
695    // Also bundle files referenced by the `[[contributes]]` block (preview
696    // HTML, theme JSON, canon dir, skill, …) so an imported pack is
697    // self-contained for the host.
698    contents.extend(carry_extension_metadata(&project_root, &mut bundle)?);
699    sort_sbom_doc(&mut bundle.sbom);
700    let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
701        PackError::new(
702            "pack.sbom_failed",
703            format!("failed to render SBOM document: {err}"),
704        )
705    })?;
706    contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
707
708    if args.sign {
709        let key_path = args.key.as_ref().expect("checked above");
710        sign_bundle(&mut bundle, &contents, key_path)?;
711    }
712
713    let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
714        PackError::new(
715            "pack.hash_failed",
716            format!("failed to compute bundle hash: {err}"),
717        )
718    })?;
719    let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
720        PackError::new(
721            "pack.archive_failed",
722            format!("failed to assemble .harnpack archive: {err}"),
723        )
724    })?;
725
726    let output_path = resolve_output_path(&args.out, &entrypoint);
727    if let Some(parent) = output_path.parent() {
728        if !parent.as_os_str().is_empty() {
729            std::fs::create_dir_all(parent).map_err(|err| {
730                PackError::new(
731                    "pack.output_dir_failed",
732                    format!("failed to create output dir {}: {err}", parent.display()),
733                )
734            })?;
735        }
736    }
737    std::fs::write(&output_path, &archive_bytes).map_err(|err| {
738        PackError::new(
739            "pack.write_failed",
740            format!("failed to write {}: {err}", output_path.display()),
741        )
742    })?;
743    let size_bytes = archive_bytes.len() as u64;
744    emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
745
746    Ok(PackOutcome {
747        bundle_hash: bundle_hash.clone(),
748        output_path: output_path.clone(),
749        size_bytes,
750        json: PackJsonData {
751            bundle_hash,
752            output_path,
753            size_bytes,
754            signature: signature_summary(&bundle),
755            sbom_summary: sbom_summary(&bundle),
756            debug_symbol_metadata,
757            manifest: bundle,
758        },
759        warnings,
760    })
761}
762
763fn sign_bundle(
764    bundle: &mut WorkflowBundle,
765    contents: &[HarnpackEntry],
766    key_path: &Path,
767) -> Result<(), PackError> {
768    let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
769        PackError::new(
770            "pack.sign_key_failed",
771            format!("failed to load signing key {}: {err}", key_path.display()),
772        )
773    })?;
774    let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
775        PackError::new(
776            "pack.hash_failed",
777            format!("failed to compute bundle hash before signing: {err}"),
778        )
779    })?;
780    let verifying_key = signing_key.verifying_key();
781    let signature = signing_key.sign(bundle_hash.as_bytes());
782    bundle.signature = Some(Ed25519Signature {
783        key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
784        public_key: hex::encode(verifying_key.to_bytes()),
785        signature: hex::encode(signature.to_bytes()),
786        manifest_hash_blake3: bundle_hash,
787        algorithm: "ed25519".to_string(),
788    });
789    Ok(())
790}
791
792fn emit_release_trust_record(
793    project_root: &Path,
794    bundle_hash: &str,
795    harn_version: &str,
796    signed: bool,
797) -> Result<TrustRecord, PackError> {
798    let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
799        PackError::new(
800            "pack.trust_log_failed",
801            format!(
802                "failed to open OpenTrustGraph event log under {}: {err}",
803                project_root.display()
804            ),
805        )
806    })?;
807    let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
808        &log,
809        &harn_vm::TrustQueryFilters::default(),
810    ))
811    .map_err(|err| {
812        PackError::new(
813            "pack.trust_query_failed",
814            format!("failed to query prior OpenTrustGraph records: {err}"),
815        )
816    })?
817    .last()
818    .map(|record| record.record_id.clone());
819    let mut record = TrustRecord::release(
820        std::env::var("USER")
821            .ok()
822            .filter(|value| !value.trim().is_empty())
823            .unwrap_or_else(|| "harn-pack".to_string()),
824        bundle_hash.to_string(),
825        harn_version.to_string(),
826        parent_trust_record_id,
827        format!("harnpack-release-{}", uuid::Uuid::now_v7()),
828        if signed {
829            AutonomyTier::ActAuto
830        } else {
831            AutonomyTier::Suggest
832        },
833    );
834    record
835        .metadata
836        .insert("signed".to_string(), serde_json::json!(signed));
837    futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
838        PackError::new(
839            "pack.trust_record_failed",
840            format!("failed to append OpenTrustGraph release record: {err}"),
841        )
842    })
843}
844
845fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
846    match &bundle.signature {
847        Some(signature) => PackSignatureSummary {
848            algorithm: signature.algorithm.clone(),
849            key_id: signature.key_id.clone(),
850            present: true,
851        },
852        None => PackSignatureSummary {
853            algorithm: "ed25519".to_string(),
854            key_id: None,
855            present: false,
856        },
857    }
858}
859
860fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
861    let stdlib_modules = bundle
862        .sbom
863        .packages
864        .iter()
865        .filter(|package| package.name.starts_with("std/"))
866        .count();
867    let providers = bundle
868        .sbom
869        .packages
870        .iter()
871        .filter(|package| package.name.starts_with("provider:"))
872        .count();
873    PackSbomSummary {
874        components: bundle.sbom.packages.len(),
875        stdlib_modules,
876        providers,
877        tools: bundle.tool_manifest.len(),
878    }
879}
880
881#[derive(Debug)]
882struct ImportedAsset {
883    path: PathBuf,
884    rel: PathBuf,
885}
886
887#[derive(Debug, Serialize, Deserialize)]
888struct SkippedAsset {
889    path: String,
890    reason: String,
891}
892
893fn discover_import_assets(
894    graph: &harn_modules::ModuleGraph,
895    module_paths: &[PathBuf],
896    project_root: &Path,
897) -> Result<Vec<ImportedAsset>, PackError> {
898    let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
899    for module_path in module_paths {
900        if module_path.to_string_lossy().starts_with("<std>/") {
901            continue;
902        }
903        for import in graph.imports_for_module(module_path) {
904            let Some(resolved_path) = import.resolved_path else {
905                continue;
906            };
907            if is_harn_module_path(&resolved_path) {
908                continue;
909            }
910            let canonical = resolved_path
911                .canonicalize()
912                .unwrap_or_else(|_| resolved_path.clone());
913            let rel = relativize(project_root, &canonical).ok_or_else(|| {
914                PackError::new(
915                    "asset.outside_root",
916                    format!(
917                        "imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
918                        canonical.display(),
919                        project_root.display()
920                    ),
921                )
922            })?;
923            assets.entry(canonical.clone()).or_insert(ImportedAsset {
924                path: canonical,
925                rel,
926            });
927        }
928    }
929    Ok(assets.into_values().collect())
930}
931
932fn is_harn_module_path(path: &Path) -> bool {
933    path.to_string_lossy().starts_with("<std>/")
934        || path.extension().and_then(|ext| ext.to_str()) == Some("harn")
935}
936
937fn sort_sbom_doc(sbom: &mut SBOMDoc) {
938    sbom.packages.sort_by(|left, right| {
939        (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
940            &right.name,
941            &right.version,
942            &right.package_hash_blake3,
943        ))
944    });
945    sbom.relationships.sort_by(|left, right| {
946        (&left.from, &left.to, &left.relationship_type).cmp(&(
947            &right.from,
948            &right.to,
949            &right.relationship_type,
950        ))
951    });
952}
953
954fn assemble_bundle(
955    entrypoint_rel: &Path,
956    transitive_modules: Vec<ModuleEntry>,
957    stdlib_version: String,
958    harn_version: String,
959    provider_catalog_hash: String,
960    tool_manifest: Vec<ToolEntry>,
961    sbom: SBOMDoc,
962    prior: Option<&WorkflowBundle>,
963) -> WorkflowBundle {
964    let stem = entrypoint_rel
965        .file_stem()
966        .map(|s| s.to_string_lossy().into_owned())
967        .unwrap_or_else(|| "harnpack".to_string());
968
969    let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
970        id: stem.clone(),
971        name: Some(stem.clone()),
972        version: "0.0.0".to_string(),
973        workflow: degenerate_workflow(&stem),
974        triggers: vec![WorkflowBundleTrigger {
975            id: "manual".to_string(),
976            kind: "manual".to_string(),
977            node_id: Some("entry".to_string()),
978            ..WorkflowBundleTrigger::default()
979        }],
980        policy: WorkflowBundlePolicy {
981            autonomy_tier: "act_with_approval".to_string(),
982            tool_policy: BTreeMap::new(),
983            approval_required: Vec::new(),
984            retry: RetryPolicySpec {
985                max_attempts: 1,
986                backoff: "none".to_string(),
987            },
988            catchup: CatchupPolicySpec {
989                mode: "none".to_string(),
990                max_events: None,
991            },
992        },
993        connectors: Vec::<ConnectorRequirement>::new(),
994        environment: EnvironmentRequirements::default(),
995        receipts: WorkflowBundleReplayMetadata::default(),
996        ..WorkflowBundle::default()
997    });
998
999    bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
1000    bundle.entrypoint = entrypoint_rel.to_path_buf();
1001    bundle.transitive_modules = transitive_modules;
1002    bundle.stdlib_version = stdlib_version;
1003    bundle.harn_version = harn_version;
1004    bundle.provider_catalog_hash = provider_catalog_hash;
1005    bundle.tool_manifest = tool_manifest;
1006    bundle.sbom = sbom;
1007    bundle.signature = None;
1008    bundle
1009}
1010
1011fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
1012    use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
1013    let mut nodes = BTreeMap::new();
1014    nodes.insert(
1015        "entry".to_string(),
1016        WorkflowNode {
1017            id: Some("entry".to_string()),
1018            kind: "action".to_string(),
1019            task_label: Some(stem.to_string()),
1020            ..WorkflowNode::default()
1021        },
1022    );
1023    WorkflowGraph {
1024        type_name: "workflow_graph".to_string(),
1025        id: format!("{stem}_pack"),
1026        name: Some(stem.to_string()),
1027        version: 1,
1028        entry: "entry".to_string(),
1029        nodes,
1030        ..WorkflowGraph::default()
1031    }
1032}
1033
1034fn type_check_or_fail(
1035    source: &str,
1036    path: &str,
1037    program: &[harn_parser::SNode],
1038) -> Result<(), PackError> {
1039    let mut had_error = false;
1040    let mut messages = String::new();
1041    for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
1042        let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
1043        if matches!(diag.severity, DiagnosticSeverity::Error) {
1044            had_error = true;
1045        }
1046        messages.push_str(&rendered);
1047    }
1048    if had_error {
1049        return Err(PackError::new(
1050            "module.type_error",
1051            format!("type errors in {path}:\n{messages}"),
1052        ));
1053    }
1054    if !messages.is_empty() {
1055        eprint!("{messages}");
1056    }
1057    Ok(())
1058}
1059
1060/// Carry the package's host-surface extension data into the signed bundle's
1061/// metadata. Reads the nearest `harn.toml`, serializing the `[[contributes]]`
1062/// block under the `contributes` key and package identity/permissions under
1063/// `extension`. No-op when there is no manifest, it fails to parse, or there
1064/// are no contributions — packing a plain workflow is unaffected.
1065fn carry_extension_metadata(
1066    project_root: &Path,
1067    bundle: &mut WorkflowBundle,
1068) -> Result<Vec<HarnpackEntry>, PackError> {
1069    let manifest_path = project_root.join("harn.toml");
1070    if !manifest_path.is_file() {
1071        return Ok(Vec::new());
1072    }
1073    let Ok(text) = std::fs::read_to_string(&manifest_path) else {
1074        return Ok(Vec::new());
1075    };
1076    let manifest: crate::package::Manifest = match toml::from_str(&text) {
1077        Ok(manifest) => manifest,
1078        Err(_) => return Ok(Vec::new()),
1079    };
1080    // Validate before sealing into the signed bundle so a malformed
1081    // `[[contributes]]` block fails the pack rather than shipping silently.
1082    crate::package::validate_contributions(&manifest)
1083        .map_err(|err| PackError::new("pack.invalid_contributes", err.message().to_string()))?;
1084    if !manifest.contributes.is_empty() {
1085        bundle.metadata.insert(
1086            "contributes".to_string(),
1087            serde_json::to_value(&manifest.contributes).map_err(|err| {
1088                PackError::new(
1089                    "pack.metadata_failed",
1090                    format!("failed to render contributions: {err}"),
1091                )
1092            })?,
1093        );
1094    }
1095    if let Some(pkg) = manifest.package.as_ref() {
1096        bundle.metadata.insert(
1097            "extension".to_string(),
1098            serde_json::json!({
1099                "name": pkg.name,
1100                "version": pkg.version,
1101                "publisher": pkg.publisher,
1102                "contact": pkg.contact,
1103                "created": pkg.created,
1104                "description": pkg.description,
1105                "license": pkg.license,
1106                "permissions": pkg.permissions,
1107                "host_requirements": pkg.host_requirements,
1108            }),
1109        );
1110    }
1111    collect_contribution_assets(project_root, &manifest.contributes)
1112}
1113
1114/// Bundle files referenced by `[[contributes]]` config (`entry`/`file`/`path`)
1115/// at their bundle-relative archive paths so a host can load them after import
1116/// (the preview HTML, theme JSON, canon dir, skill, …). `harn pack` otherwise
1117/// only walks the entrypoint's transitive imports, which never reach these.
1118/// Paths must stay inside the archive root; `..` escapes are skipped.
1119fn collect_contribution_assets(
1120    project_root: &Path,
1121    contributes: &[crate::package::ContributionEntry],
1122) -> Result<Vec<HarnpackEntry>, PackError> {
1123    const ASSET_KEYS: [&str; 3] = ["entry", "file", "path"];
1124    let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1125    let mut entries: Vec<HarnpackEntry> = Vec::new();
1126    for contribution in contributes {
1127        for key in ASSET_KEYS {
1128            let Some(rel) = contribution.config.get(key).and_then(|v| v.as_str()) else {
1129                continue;
1130            };
1131            let rel_path = PathBuf::from(rel);
1132            // Reject absolute paths and `..` escapes.
1133            if rel_path.is_absolute()
1134                || rel_path
1135                    .components()
1136                    .any(|c| matches!(c, Component::ParentDir | Component::RootDir))
1137            {
1138                continue;
1139            }
1140            collect_path(project_root, &rel_path, &mut seen, &mut entries)?;
1141        }
1142    }
1143    Ok(entries)
1144}
1145
1146/// Recursively add `rel` (file or directory, relative to `project_root`) to the
1147/// archive at its bundle-relative path. No-op when the path is missing.
1148fn collect_path(
1149    project_root: &Path,
1150    rel: &Path,
1151    seen: &mut std::collections::BTreeSet<PathBuf>,
1152    entries: &mut Vec<HarnpackEntry>,
1153) -> Result<(), PackError> {
1154    let abs = project_root.join(rel);
1155    let metadata = match std::fs::symlink_metadata(&abs) {
1156        Ok(metadata) => metadata,
1157        Err(_) => return Ok(()),
1158    };
1159    if metadata.file_type().is_symlink() {
1160        return Ok(()); // never follow symlinks out of the archive root
1161    }
1162    if metadata.is_dir() {
1163        let mut children: Vec<PathBuf> = std::fs::read_dir(&abs)
1164            .map_err(|err| {
1165                PackError::new(
1166                    "asset.read_failed",
1167                    format!("failed to read contribution dir {}: {err}", abs.display()),
1168                )
1169            })?
1170            .filter_map(|e| e.ok().map(|e| e.file_name()))
1171            .map(|name| rel.join(name))
1172            .collect();
1173        children.sort();
1174        for child in children {
1175            collect_path(project_root, &child, seen, entries)?;
1176        }
1177        return Ok(());
1178    }
1179    if !metadata.is_file() || !seen.insert(rel.to_path_buf()) {
1180        return Ok(());
1181    }
1182    let bytes = std::fs::read(&abs).map_err(|err| {
1183        PackError::new(
1184            "asset.read_failed",
1185            format!("failed to read contribution asset {}: {err}", abs.display()),
1186        )
1187    })?;
1188    entries.push(HarnpackEntry::new(
1189        PathBuf::from(logical_bundle_path(rel)),
1190        bytes,
1191    ));
1192    Ok(())
1193}
1194
1195fn pack_archive_root(entrypoint: &Path) -> PathBuf {
1196    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1197    harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
1198}
1199
1200fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
1201    let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1202    let target_canon = target
1203        .canonicalize()
1204        .unwrap_or_else(|_| target.to_path_buf());
1205    if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
1206        return Some(rel.to_path_buf());
1207    }
1208    None
1209}
1210
1211pub(super) fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
1212    let stem = rel.file_stem()?.to_string_lossy().into_owned();
1213    if stem.is_empty() {
1214        return None;
1215    }
1216    let parent_components: Vec<Component<'_>> = rel
1217        .parent()
1218        .map(|p| p.components().collect())
1219        .unwrap_or_default();
1220    let mut adjacent = PathBuf::new();
1221    for component in parent_components {
1222        adjacent.push(component.as_os_str());
1223    }
1224    let mut filename = stem;
1225    filename.push('.');
1226    filename.push_str(extension);
1227    adjacent.push(filename);
1228    Some(adjacent)
1229}
1230
1231pub(super) fn blake3_hash(bytes: &[u8]) -> String {
1232    format!("blake3:{}", blake3::hash(bytes))
1233}
1234
1235fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
1236    if let Some(path) = out {
1237        return path.clone();
1238    }
1239    let stem = entrypoint
1240        .file_stem()
1241        .map(|s| s.to_string_lossy().into_owned())
1242        .unwrap_or_else(|| "bundle".to_string());
1243    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1244    parent.join(format!("{stem}.harnpack"))
1245}
1246
1247/// Heuristic gate for `--exclude-secrets`. Matches `.env`, `.env.*`,
1248/// `*.pem`, `*.key`, `credentials*`, and any path under a `secrets/`
1249/// directory. Kept conservative so false positives don't strand
1250/// legitimate bundles; mirrors common git secret-scanning policies.
1251pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
1252    let lower_name = path
1253        .file_name()
1254        .map(|s| s.to_string_lossy().to_ascii_lowercase())
1255        .unwrap_or_default();
1256    if lower_name == ".env" || lower_name.starts_with(".env.") {
1257        return true;
1258    }
1259    if lower_name.starts_with("credentials") {
1260        return true;
1261    }
1262    if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1263        let ext = ext.to_ascii_lowercase();
1264        if ext == "pem" || ext == "key" {
1265            return true;
1266        }
1267    }
1268    for component in path.components() {
1269        if let Component::Normal(part) = component {
1270            if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
1271                return true;
1272            }
1273        }
1274    }
1275    false
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281    use std::fs;
1282
1283    fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
1284        BuildArgs {
1285            entrypoint,
1286            out: Some(out),
1287            upgrade: None,
1288            sign: false,
1289            key: None,
1290            unsigned: true,
1291            exclude_secrets: false,
1292            json: true,
1293        }
1294    }
1295
1296    #[test]
1297    fn carry_extension_metadata_injects_contributes_and_identity() {
1298        let temp = tempfile::tempdir().unwrap();
1299        fs::write(
1300            temp.path().join("harn.toml"),
1301            r#"
1302[package]
1303name = "harn-latex"
1304version = "0.1.0"
1305publisher = "Burin Labs"
1306permissions = ["workspace:read_text"]
1307
1308[[contributes]]
1309kind = "editor.language"
1310id = "latex"
1311scopes = ["workspace:read_text"]
1312languageId = "latex"
1313"#,
1314        )
1315        .unwrap();
1316        let mut bundle = WorkflowBundle::default();
1317        carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1318
1319        let contributes = bundle
1320            .metadata
1321            .get("contributes")
1322            .expect("contributes carried");
1323        assert_eq!(contributes.as_array().unwrap().len(), 1);
1324        assert_eq!(contributes[0]["kind"], "editor.language");
1325        // kind-specific keys are flattened into the contribution object
1326        assert_eq!(contributes[0]["languageId"], "latex");
1327
1328        let ext = bundle.metadata.get("extension").expect("identity carried");
1329        assert_eq!(ext["name"], "harn-latex");
1330        assert_eq!(ext["publisher"], "Burin Labs");
1331        assert_eq!(ext["permissions"][0], "workspace:read_text");
1332    }
1333
1334    #[test]
1335    fn carry_extension_metadata_bundles_contribution_assets() {
1336        let temp = tempfile::tempdir().unwrap();
1337        fs::create_dir_all(temp.path().join("assets")).unwrap();
1338        fs::write(temp.path().join("assets/preview.html"), "<html></html>").unwrap();
1339        fs::create_dir_all(temp.path().join("canon/latex")).unwrap();
1340        fs::write(temp.path().join("canon/latex/invariants.harn"), "// rules").unwrap();
1341        fs::write(temp.path().join("SKILL.md"), "# skill").unwrap();
1342        fs::write(
1343            temp.path().join("harn.toml"),
1344            r#"
1345[package]
1346name = "harn-latex"
1347permissions = ["workspace:read_text"]
1348
1349[[contributes]]
1350kind = "editor.preview"
1351id = "p"
1352scopes = ["workspace:read_text"]
1353entry = "assets/preview.html"
1354
1355[[contributes]]
1356kind = "harn.canon"
1357id = "c"
1358path = "canon/latex"
1359
1360[[contributes]]
1361kind = "harn.skill"
1362id = "s"
1363path = "SKILL.md"
1364"#,
1365        )
1366        .unwrap();
1367        let mut bundle = WorkflowBundle::default();
1368        let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1369        let paths: std::collections::BTreeSet<String> = assets
1370            .iter()
1371            .map(|e| crate::format::slash_path(&e.path))
1372            .collect();
1373        assert!(paths.contains("assets/preview.html"), "{paths:?}");
1374        assert!(paths.contains("canon/latex/invariants.harn"), "{paths:?}");
1375        assert!(paths.contains("SKILL.md"), "{paths:?}");
1376    }
1377
1378    #[test]
1379    fn carry_extension_metadata_skips_parent_escape_assets() {
1380        let temp = tempfile::tempdir().unwrap();
1381        fs::write(
1382            temp.path().join("harn.toml"),
1383            "[package]\nname = \"x\"\npermissions = []\n\n[[contributes]]\nkind = \"editor.preview\"\nid = \"p\"\nentry = \"../escape.html\"\n",
1384        )
1385        .unwrap();
1386        let mut bundle = WorkflowBundle::default();
1387        let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1388        assert!(assets.is_empty());
1389    }
1390
1391    #[test]
1392    fn carry_extension_metadata_is_noop_without_manifest() {
1393        let temp = tempfile::tempdir().unwrap();
1394        let mut bundle = WorkflowBundle::default();
1395        carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1396        assert!(!bundle.metadata.contains_key("contributes"));
1397    }
1398
1399    #[test]
1400    fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
1401        let temp = tempfile::tempdir().unwrap();
1402        fs::write(
1403            temp.path().join("harn.toml"),
1404            "[package]\nname = \"pack-root\"\n",
1405        )
1406        .unwrap();
1407        fs::create_dir_all(temp.path().join("scripts")).unwrap();
1408        fs::create_dir_all(temp.path().join("assets")).unwrap();
1409        fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
1410        fs::write(
1411            temp.path().join("scripts/entry.harn"),
1412            "import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
1413        )
1414        .unwrap();
1415
1416        let outcome = build(&build_args(
1417            temp.path().join("scripts/entry.harn"),
1418            temp.path().join("bundle.harnpack"),
1419        ))
1420        .unwrap();
1421
1422        assert_eq!(
1423            outcome.json.manifest.entrypoint,
1424            PathBuf::from("scripts/entry.harn")
1425        );
1426        assert!(outcome
1427            .json
1428            .manifest
1429            .sbom
1430            .packages
1431            .iter()
1432            .any(|package| package.name == "asset:assets/prompt.txt"));
1433    }
1434
1435    #[test]
1436    fn pack_rejects_imported_asset_outside_archive_root() {
1437        let temp = tempfile::tempdir().unwrap();
1438        let root = temp.path().join("root");
1439        let outside = temp.path().join("outside");
1440        fs::create_dir_all(&root).unwrap();
1441        fs::create_dir_all(&outside).unwrap();
1442        fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
1443        fs::write(
1444            root.join("entry.harn"),
1445            "import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
1446        )
1447        .unwrap();
1448
1449        let err = build(&build_args(
1450            root.join("entry.harn"),
1451            root.join("bundle.harnpack"),
1452        ))
1453        .unwrap_err();
1454
1455        assert_eq!(err.code, "asset.outside_root");
1456        assert!(!root.join("bundle.harnpack").exists());
1457    }
1458}