use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::process;
use ed25519_dalek::Signer;
use ed25519_dalek::VerifyingKey;
use harn_parser::DiagnosticSeverity;
use harn_vm::bytecode_cache;
use harn_vm::module_artifact;
use harn_vm::orchestration::{
build_harnpack, load_workflow_bundle_any_version, read_harnpack,
verify_workflow_bundle_signature, workflow_bundle_hash, CatchupPolicySpec,
ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
WORKFLOW_BUNDLE_SCHEMA_VERSION,
};
use harn_vm::Compiler;
use harn_vm::{AutonomyTier, TrustRecord};
use serde::{Deserialize, Serialize};
use crate::cli::{PackArgs, PackCommand, PackVerifyArgs};
use crate::command_error;
use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput, JsonWarning};
use crate::parse_source_file;
use crate::skill_provenance;
pub const PACK_SCHEMA_VERSION: u32 = 2;
pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
#[derive(Debug, Clone, Serialize)]
pub struct PackJsonData {
pub bundle_hash: String,
pub output_path: PathBuf,
pub size_bytes: u64,
pub signature: PackSignatureSummary,
pub sbom_summary: PackSbomSummary,
pub debug_symbol_metadata: PackDebugSymbolMetadata,
pub manifest: WorkflowBundle,
}
#[derive(Debug, Clone, Serialize)]
pub struct PackSignatureSummary {
pub algorithm: String,
pub key_id: Option<String>,
pub present: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct PackSbomSummary {
pub components: usize,
pub stdlib_modules: usize,
pub providers: usize,
pub tools: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PackDebugSymbolMetadata {
pub harnbc_count: usize,
pub total_bytes: u64,
}
struct PackJsonOutput {
data: PackJsonData,
warnings: Vec<JsonWarning>,
}
impl JsonOutput for PackJsonOutput {
const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
type Data = PackJsonData;
fn into_envelope(self) -> JsonEnvelope<Self::Data> {
let mut envelope = JsonEnvelope::ok(Self::SCHEMA_VERSION, self.data);
envelope.warnings = self.warnings;
envelope
}
}
pub fn run(args: PackArgs) {
if let Some(command) = args.command {
match command {
PackCommand::Verify(verify_args) => return run_verify(verify_args),
}
}
let Some(entrypoint) = args.entrypoint.clone() else {
command_error("harn pack requires an entrypoint or a subcommand (see `harn pack --help`)");
};
let build_args = BuildArgs {
entrypoint,
out: args.out,
upgrade: args.upgrade,
sign: args.sign,
key: args.key,
unsigned: args.unsigned,
exclude_secrets: args.exclude_secrets,
json: args.json,
};
match build(&build_args) {
Ok(outcome) => {
if build_args.json {
let envelope = PackJsonOutput {
data: outcome.json,
warnings: outcome.warnings,
}
.into_envelope();
println!("{}", to_string_pretty(&envelope));
} else {
for warning in &outcome.warnings {
eprintln!("warning[{}]: {}", warning.code, warning.message);
}
println!(
"wrote {} ({} bytes, bundle_hash {})",
outcome.output_path.display(),
outcome.size_bytes,
outcome.bundle_hash
);
}
}
Err(err) => {
if build_args.json {
let envelope: JsonEnvelope<PackJsonData> =
JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
println!("{}", to_string_pretty(&envelope));
process::exit(1);
}
command_error(&err.message);
}
}
}
pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
let Some(entrypoint) = args.entrypoint.clone() else {
return JsonEnvelope::err(
PACK_SCHEMA_VERSION,
"pack.missing_entrypoint",
"harn pack requires an entrypoint or a subcommand".to_string(),
);
};
let build_args = BuildArgs {
entrypoint,
out: args.out.clone(),
upgrade: args.upgrade.clone(),
sign: args.sign,
key: args.key.clone(),
unsigned: args.unsigned,
exclude_secrets: args.exclude_secrets,
json: args.json,
};
match build(&build_args) {
Ok(outcome) => PackJsonOutput {
data: outcome.json,
warnings: outcome.warnings,
}
.into_envelope(),
Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
}
}
#[derive(Debug, Clone)]
pub struct BuildArgs {
pub entrypoint: PathBuf,
pub out: Option<PathBuf>,
pub upgrade: Option<PathBuf>,
pub sign: bool,
pub key: Option<PathBuf>,
pub unsigned: bool,
pub exclude_secrets: bool,
pub json: bool,
}
pub fn json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "harn pack --json",
"type": "object",
"required": ["schemaVersion", "ok", "data", "warnings"],
"properties": {
"schemaVersion": { "const": PACK_SCHEMA_VERSION },
"ok": { "const": true },
"warnings": { "type": "array" },
"data": {
"type": "object",
"required": [
"bundle_hash",
"output_path",
"size_bytes",
"signature",
"sbom_summary",
"debug_symbol_metadata",
"manifest"
],
"properties": {
"bundle_hash": { "type": "string", "pattern": "^blake3:" },
"output_path": { "type": "string", "minLength": 1 },
"size_bytes": { "type": "integer", "minimum": 1 },
"signature": {
"type": "object",
"required": ["algorithm", "key_id", "present"],
"properties": {
"algorithm": { "const": "ed25519" },
"key_id": { "type": ["string", "null"] },
"present": { "type": "boolean" }
}
},
"sbom_summary": {
"type": "object",
"required": ["components", "stdlib_modules", "providers", "tools"],
"properties": {
"components": { "type": "integer", "minimum": 1 },
"stdlib_modules": { "type": "integer", "minimum": 0 },
"providers": { "type": "integer", "minimum": 0 },
"tools": { "type": "integer", "minimum": 0 }
}
},
"debug_symbol_metadata": {
"type": "object",
"required": ["harnbc_count", "total_bytes"],
"properties": {
"harnbc_count": { "type": "integer", "minimum": 1 },
"total_bytes": { "type": "integer", "minimum": 1 }
}
},
"manifest": { "type": "object" }
}
}
}
})
}
#[derive(Debug)]
pub struct PackOutcome {
pub bundle_hash: String,
pub output_path: PathBuf,
pub size_bytes: u64,
pub json: PackJsonData,
pub warnings: Vec<JsonWarning>,
}
#[derive(Debug)]
pub struct PackError {
pub code: &'static str,
pub message: String,
}
impl std::fmt::Display for PackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for PackError {}
impl PackError {
fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
pub fn build(args: &BuildArgs) -> Result<PackOutcome, PackError> {
if args.sign && args.unsigned {
return Err(PackError::new(
"pack.sign_conflict",
"--sign and --unsigned cannot be used together",
));
}
if args.sign && args.key.is_none() {
return Err(PackError::new(
"pack.sign_missing_key",
"--sign requires --key <path>",
));
}
if !args.sign && args.key.is_some() {
return Err(PackError::new(
"pack.key_without_sign",
"--key requires --sign",
));
}
if let Some(upgrade) = &args.upgrade {
if !upgrade.exists() {
return Err(PackError::new(
"upgrade.not_found",
format!(
"--upgrade source bundle does not exist: {}",
upgrade.display()
),
));
}
}
let entrypoint_input = args.entrypoint.clone();
let entrypoint = entrypoint_input
.canonicalize()
.unwrap_or_else(|_| entrypoint_input.clone());
if !entrypoint.exists() {
return Err(PackError::new(
"entrypoint.not_found",
format!("entrypoint does not exist: {}", entrypoint_input.display()),
));
}
if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
{
return Err(PackError::new(
"entrypoint.invalid",
format!(
"entrypoint must be a .harn file: {}",
entrypoint_input.display()
),
));
}
if args.exclude_secrets && path_looks_like_secret(&entrypoint) {
return Err(PackError::new(
"pack.secret_blocked",
format!(
"entrypoint {} matches a secret-bearing path pattern; \
re-run with --include-secrets to override",
entrypoint_input.display()
),
));
}
let project_root = pack_archive_root(&entrypoint);
let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
PackError::new(
"entrypoint.outside_root",
format!(
"entrypoint {} could not be relativized against {}",
entrypoint.display(),
project_root.display()
),
)
})?;
let prior = match &args.upgrade {
Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
PackError::new(
"upgrade.read_failed",
format!("failed to read --upgrade source {}: {err}", path.display()),
)
})?),
None => None,
};
let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
let mut graph_paths = graph.module_paths();
graph_paths.sort();
let mut module_paths: Vec<PathBuf> = graph_paths
.iter()
.filter(|path| is_harn_module_path(path))
.cloned()
.collect();
module_paths.sort();
let mut transitive_modules = Vec::new();
let mut contents = Vec::new();
let mut sbom_packages = Vec::new();
let mut sbom_relationships = Vec::new();
let mut warnings = Vec::new();
let mut skipped_assets = Vec::new();
let mut debug_symbol_metadata = PackDebugSymbolMetadata {
harnbc_count: 0,
total_bytes: 0,
};
let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
let harn_version = bytecode_cache::HARN_VERSION.to_string();
sbom_packages.push(SBOMPackage {
name: "harn-stdlib".to_string(),
version: Some(stdlib_version.clone()),
package_hash_blake3: None,
license: None,
});
for module_path in &module_paths {
let module_str = module_path.to_string_lossy().to_string();
if module_str.starts_with("<std>/") {
let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
sbom_packages.push(SBOMPackage {
name: format!("std/{stdlib_name}"),
version: Some(stdlib_version.clone()),
package_hash_blake3: None,
license: None,
});
sbom_relationships.push(SBOMRelationship {
from: format!("entrypoint:{}", entrypoint_rel.display()),
to: format!("std/{stdlib_name}"),
relationship_type: "depends_on".to_string(),
});
continue;
}
let source = std::fs::read_to_string(module_path).map_err(|err| {
PackError::new(
"module.read_failed",
format!("failed to read {}: {err}", module_path.display()),
)
})?;
let (parsed_source, program) = parse_source_file(&module_str);
debug_assert_eq!(parsed_source, source);
type_check_or_fail(&source, &module_str, &program)?;
let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
PackError::new(
"module.compile_failed",
format!("compile error in {}: {err}", module_path.display()),
)
})?;
let module_artifact_opt =
module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
.map_err(|err| {
PackError::new(
"module.serialize_failed",
format!(
"failed to serialize chunk for {}: {err}",
module_path.display()
),
)
})?;
let module_artifact_bytes = match module_artifact_opt.as_ref() {
Some(artifact) => Some(
bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
PackError::new(
"module.serialize_failed",
format!(
"failed to serialize module artifact for {}: {err}",
module_path.display()
),
)
})?,
),
None => None,
};
let rel = relativize(&project_root, module_path).ok_or_else(|| {
PackError::new(
"module.outside_root",
format!(
"module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
module_path.display(),
project_root.display()
),
)
})?;
let source_archive_path = PathBuf::from("sources").join(&rel);
let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
.ok_or_else(|| {
PackError::new(
"module.invalid_path",
format!("module path has no stem: {}", module_path.display()),
)
})?;
let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
let source_hash = blake3_hash(source.as_bytes());
let harnbc_hash = blake3_hash(&chunk_bytes);
debug_symbol_metadata.harnbc_count += 1;
debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
transitive_modules.push(ModuleEntry {
path: rel.clone(),
source_hash_blake3: source_hash.clone(),
harnbc_hash_blake3: harnbc_hash.clone(),
});
contents.push(HarnpackEntry::new(
source_archive_path,
source.as_bytes().to_vec(),
));
contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
if let Some(artifact_bytes) = module_artifact_bytes {
debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
.ok_or_else(|| {
PackError::new(
"module.invalid_path",
format!("module path has no stem: {}", module_path.display()),
)
})?;
let module_archive_path = PathBuf::from("bytecode").join(module_rel);
contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
}
if module_path != &entrypoint {
sbom_relationships.push(SBOMRelationship {
from: format!("entrypoint:{}", entrypoint_rel.display()),
to: format!("module:{}", rel.display()),
relationship_type: "depends_on".to_string(),
});
}
sbom_packages.push(SBOMPackage {
name: format!("module:{}", rel.display()),
version: Some(harn_version.clone()),
package_hash_blake3: Some(source_hash),
license: None,
});
}
for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
if args.exclude_secrets && path_looks_like_secret(&asset.path) {
warnings.push(JsonWarning {
code: "pack.asset_skipped_secret".to_string(),
message: format!(
"skipped imported asset {} because it matches a secret-bearing path pattern",
asset.rel.display()
),
});
skipped_assets.push(SkippedAsset {
path: asset.rel.clone(),
reason: "secret_path".to_string(),
});
continue;
}
let bytes = std::fs::read(&asset.path).map_err(|err| {
PackError::new(
"asset.read_failed",
format!(
"failed to read imported asset {}: {err}",
asset.path.display()
),
)
})?;
let asset_hash = blake3_hash(&bytes);
contents.push(HarnpackEntry::new(
PathBuf::from("sources").join(&asset.rel),
bytes,
));
sbom_packages.push(SBOMPackage {
name: format!("asset:{}", asset.rel.display()),
version: Some(harn_version.clone()),
package_hash_blake3: Some(asset_hash),
license: None,
});
sbom_relationships.push(SBOMRelationship {
from: format!("entrypoint:{}", entrypoint_rel.display()),
to: format!("asset:{}", asset.rel.display()),
relationship_type: "depends_on".to_string(),
});
}
if transitive_modules.is_empty() {
return Err(PackError::new(
"pack.no_modules",
format!(
"no Harn modules resolved from entrypoint {}",
entrypoint.display()
),
));
}
let provider_catalog = harn_vm::provider_catalog::artifact();
let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
PackError::new(
"provider_catalog.failed",
format!("failed to serialize provider catalog snapshot: {err}"),
)
})?;
let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
sbom_packages.push(SBOMPackage {
name: "harn-provider-catalog".to_string(),
version: Some(harn_version.clone()),
package_hash_blake3: Some(provider_catalog_hash.clone()),
license: None,
});
sbom_relationships.push(SBOMRelationship {
from: format!("entrypoint:{}", entrypoint_rel.display()),
to: "harn-provider-catalog".to_string(),
relationship_type: "depends_on".to_string(),
});
for provider in &provider_catalog.providers {
let provider_name = format!("provider:{}", provider.id);
sbom_packages.push(SBOMPackage {
name: provider_name.clone(),
version: None,
package_hash_blake3: None,
license: None,
});
sbom_relationships.push(SBOMRelationship {
from: "harn-provider-catalog".to_string(),
to: provider_name,
relationship_type: "contains".to_string(),
});
}
let tool_manifest: Vec<ToolEntry> = Vec::new();
for tool in &tool_manifest {
sbom_packages.push(SBOMPackage {
name: format!("tool:{}", tool.name),
version: None,
package_hash_blake3: tool.schema_hash_blake3.clone(),
license: None,
});
sbom_relationships.push(SBOMRelationship {
from: format!("entrypoint:{}", entrypoint_rel.display()),
to: format!("tool:{}", tool.name),
relationship_type: "depends_on".to_string(),
});
}
let mut bundle = assemble_bundle(
&entrypoint_rel,
transitive_modules,
stdlib_version,
harn_version,
provider_catalog_hash,
tool_manifest,
SBOMDoc {
format: "spdx-lite".to_string(),
version: "2.3".to_string(),
packages: sbom_packages,
relationships: sbom_relationships,
},
prior.as_ref(),
);
if !skipped_assets.is_empty() {
bundle.metadata.insert(
"skipped_assets".to_string(),
serde_json::to_value(&skipped_assets).map_err(|err| {
PackError::new(
"pack.metadata_failed",
format!("failed to render skipped asset metadata: {err}"),
)
})?,
);
}
sort_sbom_doc(&mut bundle.sbom);
let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
PackError::new(
"pack.sbom_failed",
format!("failed to render SBOM document: {err}"),
)
})?;
contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
if args.sign {
let key_path = args.key.as_ref().expect("checked above");
sign_bundle(&mut bundle, &contents, key_path)?;
}
let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
PackError::new(
"pack.hash_failed",
format!("failed to compute bundle hash: {err}"),
)
})?;
let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
PackError::new(
"pack.archive_failed",
format!("failed to assemble .harnpack archive: {err}"),
)
})?;
let output_path = resolve_output_path(&args.out, &entrypoint);
if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|err| {
PackError::new(
"pack.output_dir_failed",
format!("failed to create output dir {}: {err}", parent.display()),
)
})?;
}
}
std::fs::write(&output_path, &archive_bytes).map_err(|err| {
PackError::new(
"pack.write_failed",
format!("failed to write {}: {err}", output_path.display()),
)
})?;
let size_bytes = archive_bytes.len() as u64;
emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
Ok(PackOutcome {
bundle_hash: bundle_hash.clone(),
output_path: output_path.clone(),
size_bytes,
json: PackJsonData {
bundle_hash,
output_path,
size_bytes,
signature: signature_summary(&bundle),
sbom_summary: sbom_summary(&bundle),
debug_symbol_metadata,
manifest: bundle,
},
warnings,
})
}
fn sign_bundle(
bundle: &mut WorkflowBundle,
contents: &[HarnpackEntry],
key_path: &Path,
) -> Result<(), PackError> {
let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
PackError::new(
"pack.sign_key_failed",
format!("failed to load signing key {}: {err}", key_path.display()),
)
})?;
let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
PackError::new(
"pack.hash_failed",
format!("failed to compute bundle hash before signing: {err}"),
)
})?;
let verifying_key = signing_key.verifying_key();
let signature = signing_key.sign(bundle_hash.as_bytes());
bundle.signature = Some(Ed25519Signature {
key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
public_key: hex_encode(&verifying_key.to_bytes()),
signature: hex_encode(&signature.to_bytes()),
manifest_hash_blake3: bundle_hash,
algorithm: "ed25519".to_string(),
});
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn emit_release_trust_record(
project_root: &Path,
bundle_hash: &str,
harn_version: &str,
signed: bool,
) -> Result<TrustRecord, PackError> {
let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
PackError::new(
"pack.trust_log_failed",
format!(
"failed to open OpenTrustGraph event log under {}: {err}",
project_root.display()
),
)
})?;
let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
&log,
&harn_vm::TrustQueryFilters::default(),
))
.map_err(|err| {
PackError::new(
"pack.trust_query_failed",
format!("failed to query prior OpenTrustGraph records: {err}"),
)
})?
.last()
.map(|record| record.record_id.clone());
let mut record = TrustRecord::release(
std::env::var("USER")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "harn-pack".to_string()),
bundle_hash.to_string(),
harn_version.to_string(),
parent_trust_record_id,
format!("harnpack-release-{}", uuid::Uuid::now_v7()),
if signed {
AutonomyTier::ActAuto
} else {
AutonomyTier::Suggest
},
);
record
.metadata
.insert("signed".to_string(), serde_json::json!(signed));
futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
PackError::new(
"pack.trust_record_failed",
format!("failed to append OpenTrustGraph release record: {err}"),
)
})
}
fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
match &bundle.signature {
Some(signature) => PackSignatureSummary {
algorithm: signature.algorithm.clone(),
key_id: signature.key_id.clone(),
present: true,
},
None => PackSignatureSummary {
algorithm: "ed25519".to_string(),
key_id: None,
present: false,
},
}
}
fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
let stdlib_modules = bundle
.sbom
.packages
.iter()
.filter(|package| package.name.starts_with("std/"))
.count();
let providers = bundle
.sbom
.packages
.iter()
.filter(|package| package.name.starts_with("provider:"))
.count();
PackSbomSummary {
components: bundle.sbom.packages.len(),
stdlib_modules,
providers,
tools: bundle.tool_manifest.len(),
}
}
#[derive(Debug)]
struct ImportedAsset {
path: PathBuf,
rel: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
struct SkippedAsset {
path: PathBuf,
reason: String,
}
fn discover_import_assets(
graph: &harn_modules::ModuleGraph,
module_paths: &[PathBuf],
project_root: &Path,
) -> Result<Vec<ImportedAsset>, PackError> {
let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
for module_path in module_paths {
if module_path.to_string_lossy().starts_with("<std>/") {
continue;
}
for import in graph.imports_for_module(module_path) {
let Some(resolved_path) = import.resolved_path else {
continue;
};
if is_harn_module_path(&resolved_path) {
continue;
}
let canonical = resolved_path
.canonicalize()
.unwrap_or_else(|_| resolved_path.clone());
let rel = relativize(project_root, &canonical).ok_or_else(|| {
PackError::new(
"asset.outside_root",
format!(
"imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
canonical.display(),
project_root.display()
),
)
})?;
assets.entry(canonical.clone()).or_insert(ImportedAsset {
path: canonical,
rel,
});
}
}
Ok(assets.into_values().collect())
}
fn is_harn_module_path(path: &Path) -> bool {
path.to_string_lossy().starts_with("<std>/")
|| path.extension().and_then(|ext| ext.to_str()) == Some("harn")
}
fn sort_sbom_doc(sbom: &mut SBOMDoc) {
sbom.packages.sort_by(|left, right| {
(&left.name, &left.version, &left.package_hash_blake3).cmp(&(
&right.name,
&right.version,
&right.package_hash_blake3,
))
});
sbom.relationships.sort_by(|left, right| {
(&left.from, &left.to, &left.relationship_type).cmp(&(
&right.from,
&right.to,
&right.relationship_type,
))
});
}
fn assemble_bundle(
entrypoint_rel: &Path,
transitive_modules: Vec<ModuleEntry>,
stdlib_version: String,
harn_version: String,
provider_catalog_hash: String,
tool_manifest: Vec<ToolEntry>,
sbom: SBOMDoc,
prior: Option<&WorkflowBundle>,
) -> WorkflowBundle {
let stem = entrypoint_rel
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "harnpack".to_string());
let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
id: stem.clone(),
name: Some(stem.clone()),
version: "0.0.0".to_string(),
workflow: degenerate_workflow(&stem),
triggers: vec![WorkflowBundleTrigger {
id: "manual".to_string(),
kind: "manual".to_string(),
node_id: Some("entry".to_string()),
..WorkflowBundleTrigger::default()
}],
policy: WorkflowBundlePolicy {
autonomy_tier: "act_with_approval".to_string(),
tool_policy: BTreeMap::new(),
approval_required: Vec::new(),
retry: RetryPolicySpec {
max_attempts: 1,
backoff: "none".to_string(),
},
catchup: CatchupPolicySpec {
mode: "none".to_string(),
max_events: None,
},
},
connectors: Vec::<ConnectorRequirement>::new(),
environment: EnvironmentRequirements::default(),
receipts: WorkflowBundleReplayMetadata::default(),
..WorkflowBundle::default()
});
bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
bundle.entrypoint = entrypoint_rel.to_path_buf();
bundle.transitive_modules = transitive_modules;
bundle.stdlib_version = stdlib_version;
bundle.harn_version = harn_version;
bundle.provider_catalog_hash = provider_catalog_hash;
bundle.tool_manifest = tool_manifest;
bundle.sbom = sbom;
bundle.signature = None;
bundle
}
fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
let mut nodes = BTreeMap::new();
nodes.insert(
"entry".to_string(),
WorkflowNode {
id: Some("entry".to_string()),
kind: "action".to_string(),
task_label: Some(stem.to_string()),
..WorkflowNode::default()
},
);
WorkflowGraph {
type_name: "workflow_graph".to_string(),
id: format!("{stem}_pack"),
name: Some(stem.to_string()),
version: 1,
entry: "entry".to_string(),
nodes,
..WorkflowGraph::default()
}
}
fn type_check_or_fail(
source: &str,
path: &str,
program: &[harn_parser::SNode],
) -> Result<(), PackError> {
let mut had_error = false;
let mut messages = String::new();
for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
if matches!(diag.severity, DiagnosticSeverity::Error) {
had_error = true;
}
messages.push_str(&rendered);
}
if had_error {
return Err(PackError::new(
"module.type_error",
format!("type errors in {path}:\n{messages}"),
));
}
if !messages.is_empty() {
eprint!("{messages}");
}
Ok(())
}
fn pack_archive_root(entrypoint: &Path) -> PathBuf {
let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
}
fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let target_canon = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
return Some(rel.to_path_buf());
}
None
}
fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
let stem = rel.file_stem()?.to_string_lossy().into_owned();
if stem.is_empty() {
return None;
}
let parent_components: Vec<Component<'_>> = rel
.parent()
.map(|p| p.components().collect())
.unwrap_or_default();
let mut adjacent = PathBuf::new();
for component in parent_components {
adjacent.push(component.as_os_str());
}
let mut filename = stem;
filename.push('.');
filename.push_str(extension);
adjacent.push(filename);
Some(adjacent)
}
fn blake3_hash(bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(bytes))
}
fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
if let Some(path) = out {
return path.clone();
}
let stem = entrypoint
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "bundle".to_string());
let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!("{stem}.harnpack"))
}
pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
let lower_name = path
.file_name()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if lower_name == ".env" || lower_name.starts_with(".env.") {
return true;
}
if lower_name.starts_with("credentials") {
return true;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let ext = ext.to_ascii_lowercase();
if ext == "pem" || ext == "key" {
return true;
}
}
for component in path.components() {
if let Component::Normal(part) = component {
if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
BuildArgs {
entrypoint,
out: Some(out),
upgrade: None,
sign: false,
key: None,
unsigned: true,
exclude_secrets: false,
json: true,
}
}
#[test]
fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
let temp = tempfile::tempdir().unwrap();
fs::write(
temp.path().join("harn.toml"),
"[package]\nname = \"pack-root\"\n",
)
.unwrap();
fs::create_dir_all(temp.path().join("scripts")).unwrap();
fs::create_dir_all(temp.path().join("assets")).unwrap();
fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
fs::write(
temp.path().join("scripts/entry.harn"),
"import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
)
.unwrap();
let outcome = build(&build_args(
temp.path().join("scripts/entry.harn"),
temp.path().join("bundle.harnpack"),
))
.unwrap();
assert_eq!(
outcome.json.manifest.entrypoint,
PathBuf::from("scripts/entry.harn")
);
assert!(outcome
.json
.manifest
.sbom
.packages
.iter()
.any(|package| package.name == "asset:assets/prompt.txt"));
}
#[test]
fn pack_rejects_imported_asset_outside_archive_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
fs::write(
root.join("entry.harn"),
"import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
)
.unwrap();
let err = build(&build_args(
root.join("entry.harn"),
root.join("bundle.harnpack"),
))
.unwrap_err();
assert_eq!(err.code, "asset.outside_root");
assert!(!root.join("bundle.harnpack").exists());
}
}
pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize)]
pub struct PackVerifyJsonData {
pub bundle: PathBuf,
pub bundle_hash: String,
pub recorded_bundle_hash: Option<String>,
pub signature_present: bool,
pub signature_verified: bool,
pub key_id: Option<String>,
pub schema_version: u32,
pub entrypoint: PathBuf,
pub module_count: usize,
pub content_entry_count: usize,
}
struct PackVerifyJsonOutput(PackVerifyJsonData);
impl JsonOutput for PackVerifyJsonOutput {
const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
type Data = PackVerifyJsonData;
fn into_envelope(self) -> JsonEnvelope<Self::Data> {
JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
}
}
pub fn verify_json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "harn pack verify --json",
"type": "object",
"required": ["schemaVersion", "ok", "data", "warnings"],
"properties": {
"schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
"ok": { "type": "boolean" },
"warnings": { "type": "array" },
"data": {
"type": "object",
"required": [
"bundle",
"bundle_hash",
"signature_present",
"signature_verified",
"recorded_bundle_hash",
"key_id",
"schema_version",
"entrypoint",
"module_count",
"content_entry_count"
],
"properties": {
"bundle": { "type": "string", "minLength": 1 },
"bundle_hash": { "type": "string", "pattern": "^blake3:" },
"recorded_bundle_hash": { "type": ["string", "null"] },
"signature_present": { "type": "boolean" },
"signature_verified": { "type": "boolean" },
"key_id": { "type": ["string", "null"] },
"schema_version": { "type": "integer", "minimum": 1 },
"entrypoint": { "type": "string", "minLength": 1 },
"module_count": { "type": "integer", "minimum": 1 },
"content_entry_count": { "type": "integer", "minimum": 1 }
}
}
}
})
}
pub fn run_verify(args: PackVerifyArgs) {
match verify(&args) {
Ok(outcome) => {
if args.json {
let envelope = PackVerifyJsonOutput(outcome).into_envelope();
println!("{}", to_string_pretty(&envelope));
} else {
println!(
"ok {} (bundle_hash {}, signature_verified={})",
outcome.bundle.display(),
outcome.bundle_hash,
outcome.signature_verified
);
}
}
Err(err) => {
if args.json {
let envelope: JsonEnvelope<PackVerifyJsonData> =
JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
println!("{}", to_string_pretty(&envelope));
process::exit(1);
}
command_error(&err.message);
}
}
}
pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
match verify(args) {
Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
}
}
pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
let bytes = std::fs::read(&args.bundle).map_err(|err| {
PackError::new(
"verify.read_failed",
format!("failed to read {}: {err}", args.bundle.display()),
)
})?;
let archive = read_harnpack(&bytes).map_err(|err| {
PackError::new(
"verify.archive_failed",
format!("failed to parse {}: {err}", args.bundle.display()),
)
})?;
let manifest = &archive.manifest;
let contents = &archive.contents;
let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
PackError::new(
"verify.hash_failed",
format!("failed to recompute bundle hash: {err}"),
)
})?;
let trust_policy = args
.trust_policy
.as_deref()
.map(skill_provenance::load_trust_policy)
.transpose()
.map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
let signature_present = manifest.signature.is_some();
let mut signature_verified = false;
let mut key_id = None;
if let Some(signature) = manifest.signature.as_ref() {
key_id = signature.key_id.clone();
verify_workflow_bundle_signature(manifest, contents)
.map_err(|err| PackError::new("verify.signature_failed", err.message.clone()))?;
if args.require_trusted_signer {
let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
PackError::new(
"verify.signature_failed",
format!("invalid bundle signer: {err}"),
)
})?;
match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
.map_err(|err| PackError::new("verify.trust_policy_failed", err))?
{
skill_provenance::TrustedSignerStatus::Trusted => {}
skill_provenance::TrustedSignerStatus::MissingSigner => {
return Err(PackError::new(
"verify.untrusted_signer",
format!(
"bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
args.bundle.display(),
signer_fingerprint
),
));
}
skill_provenance::TrustedSignerStatus::UntrustedSigner => {
return Err(PackError::new(
"verify.untrusted_signer",
format!(
"bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
args.bundle.display(),
signer_fingerprint
),
));
}
}
}
signature_verified = true;
key_id.get_or_insert(
signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
PackError::new(
"verify.signature_failed",
format!("invalid bundle signer: {err}"),
)
})?,
);
} else if args.require_trusted_signer {
return Err(PackError::new(
"verify.untrusted_signer",
format!(
"bundle {} is unsigned and cannot satisfy --require-trusted-signer",
args.bundle.display()
),
));
} else if !args.allow_unsigned {
return Err(PackError::new(
"verify.unsigned",
format!(
"refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
args.bundle.display()
),
));
}
let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
for entry in contents {
archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
if let Ok(rel) = entry.path.strip_prefix("sources") {
source_map.insert(rel.to_path_buf(), entry);
} else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
bytecode_map.insert(rel.to_path_buf(), entry);
}
}
for module in &manifest.transitive_modules {
let source_entry = source_map.get(&module.path).ok_or_else(|| {
PackError::new(
"verify.module_missing",
format!(
"manifest lists module {} but archive has no sources/{} entry",
module.path.display(),
module.path.display()
),
)
})?;
let actual_source = blake3_hash(&source_entry.bytes);
if actual_source != module.source_hash_blake3 {
return Err(PackError::new(
"verify.source_mismatch",
format!(
"source hash mismatch for {}: manifest {}, archive {}",
module.path.display(),
module.source_hash_blake3,
actual_source
),
));
}
let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
.ok_or_else(|| {
PackError::new(
"verify.module_invalid_path",
format!("module {} has no stem", module.path.display()),
)
})?;
let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
PackError::new(
"verify.module_missing",
format!(
"manifest lists bytecode for {} but archive has no bytecode/{} entry",
module.path.display(),
chunk_rel.display()
),
)
})?;
let actual_harnbc = blake3_hash(&chunk_entry.bytes);
if actual_harnbc != module.harnbc_hash_blake3 {
return Err(PackError::new(
"verify.bytecode_mismatch",
format!(
"bytecode hash mismatch for {}: manifest {}, archive {}",
module.path.display(),
module.harnbc_hash_blake3,
actual_harnbc
),
));
}
}
if args.strict {
verify_sbom_package_hashes(manifest, &archive_hashes)?;
}
let recorded_bundle_hash = manifest
.signature
.as_ref()
.map(|sig| sig.manifest_hash_blake3.clone());
if let Some(recorded) = &recorded_bundle_hash {
if recorded != &expected_hash {
return Err(PackError::new(
"verify.recorded_hash_mismatch",
format!(
"recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
),
));
}
}
Ok(PackVerifyJsonData {
bundle: args.bundle.clone(),
bundle_hash: expected_hash,
recorded_bundle_hash,
signature_present,
signature_verified,
key_id,
schema_version: manifest.schema_version,
entrypoint: manifest.entrypoint.clone(),
module_count: manifest.transitive_modules.len(),
content_entry_count: contents.len(),
})
}
fn verify_sbom_package_hashes(
manifest: &WorkflowBundle,
archive_hashes: &BTreeMap<PathBuf, String>,
) -> Result<(), PackError> {
let module_hashes: BTreeMap<&Path, &str> = manifest
.transitive_modules
.iter()
.map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
.collect();
for package in &manifest.sbom.packages {
let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
continue;
};
if let Some(rel) = package.name.strip_prefix("module:") {
let module_path = Path::new(rel);
let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} does not match any manifest transitive module",
package.name
),
)
})?;
if *manifest_hash != expected_hash {
return Err(PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} recorded hash {} but manifest module {} uses {}",
package.name,
expected_hash,
module_path.display(),
manifest_hash
),
));
}
let source_archive_path = PathBuf::from("sources").join(module_path);
let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} refers to {}, but archive is missing {}",
package.name,
module_path.display(),
source_archive_path.display()
),
)
})?;
if archive_hash != expected_hash {
return Err(PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} recorded hash {} but archive {} hashes to {}",
package.name,
expected_hash,
source_archive_path.display(),
archive_hash
),
));
}
continue;
}
if let Some(rel) = package.name.strip_prefix("asset:") {
let asset_archive_path = PathBuf::from("sources").join(rel);
let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} refers to {}, but archive is missing {}",
package.name,
rel,
asset_archive_path.display()
),
)
})?;
if archive_hash != expected_hash {
return Err(PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} recorded hash {} but archive {} hashes to {}",
package.name,
expected_hash,
asset_archive_path.display(),
archive_hash
),
));
}
continue;
}
let candidate_path = Path::new(&package.name);
let Some(archive_hash) = archive_hashes.get(candidate_path) else {
continue;
};
if archive_hash != expected_hash {
return Err(PackError::new(
"verify.sbom_mismatch",
format!(
"SBOM package {} recorded hash {} but archive {} hashes to {}",
package.name,
expected_hash,
candidate_path.display(),
archive_hash
),
));
}
}
Ok(())
}
fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
match signature.key_id.as_deref() {
Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
_ => signer_fingerprint_from_public_key(&signature.public_key),
}
}
fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
let public_key_bytes = decode_hex_32(public_key_hex)?;
let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
format!("workflow bundle signature public_key is invalid Ed25519: {error}")
})?;
Ok(skill_provenance::fingerprint_for_key(&verifying_key))
}
fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
let trimmed = raw.trim();
if trimmed.len() != 64 {
return Err(format!(
"workflow bundle signature public_key must be 64 hex characters, found {}",
trimmed.len()
));
}
let mut bytes = [0_u8; 32];
for (idx, slot) in bytes.iter_mut().enumerate() {
let start = idx * 2;
let end = start + 2;
*slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
format!(
"workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
)
})?;
}
Ok(bytes)
}