stemedb/applications/aphoria/src/policy_ops.rs
jordan 41c676a78e feat: Aphoria enterprise features + ontology SDK + file length compliance
Enterprise Features:
- Hosted mode with remote sync for team pattern aggregation
- Community sharing with privacy-preserving anonymization
- LLM-based semantic claim extraction with Gemini integration
- Pattern learning with promotion to declarative extractors
- High-entropy secrets extractor with configurable thresholds
- Auth bypass and insecure cookies extractors

Module Refactoring:
- Split oversized files to comply with 500-line limit
- Config split: types/core.rs, types/extractors.rs, types/hosted.rs, etc.
- Handlers split: scan.rs, policy.rs, report.rs modules
- Extractors split: declarative/, high_entropy_secrets/, insecure_cookies/
- Learning split: store modules with metrics and persistence

SDK & Ontology:
- stemedb-ontology SDK with fluent builders and StemeDB client
- Pharma domain extractors for FDA Orange Book data
- Consumer health UAT test infrastructure

Code Quality:
- Fixed clippy warnings (needless_borrows_for_generic_args)
- Added KVStore trait imports where needed
- Fixed utoipa path re-exports for OpenAPI docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:55:29 -07:00

453 lines
15 KiB
Rust

//! Policy export and import operations.
use crate::bridge;
use crate::config::AphoriaConfig;
use crate::episteme::LocalEpisteme;
use crate::error::AphoriaError;
use crate::policy::{PackPredicateAliasSet, SignatureRecord, TrustPack};
use crate::types::{predicates, AcknowledgeArgs, ExtractedClaim, UpdateArgs};
use std::path::PathBuf;
use tracing::{info, instrument, warn};
/// Export policy from the current project.
///
/// Collects all acknowledged conflicts, blessed patterns, and manual aliases into a Trust Pack.
#[instrument(skip(config))]
pub async fn export_policy(
name: String,
output: PathBuf,
config: &AphoriaConfig,
) -> Result<(), AphoriaError> {
info!(name, output = %output.display(), "Exporting policy");
let project_root = std::env::current_dir()?;
let episteme = LocalEpisteme::open(config, &project_root).await?;
// Fetch acknowledgments (assertions with predicate="acknowledged")
let mut assertions = episteme.fetch_acknowledgments().await?;
let ack_count = assertions.len();
// Fetch blessed assertions (patterns blessed as authoritative standards)
let blessed = episteme.fetch_blessed_assertions().await?;
let blessed_count = blessed.len();
assertions.extend(blessed);
info!(
acknowledged = ack_count,
blessed = blessed_count,
total = assertions.len(),
"Collected assertions for export"
);
// Fetch manual aliases
let aliases = episteme.fetch_manual_aliases().await?;
// Sign with agent key
let signing_key = bridge::load_or_generate_key(&project_root)?;
// Include predicate aliases from config
let predicate_aliases: Vec<PackPredicateAliasSet> =
config.predicate_aliases.to_alias_sets().iter().map(PackPredicateAliasSet::from).collect();
info!(
predicate_alias_sets = predicate_aliases.len(),
"Including predicate aliases from config"
);
let pack = TrustPack::new_with_predicate_aliases(
name,
"0.1.0".to_string(),
assertions,
aliases,
predicate_aliases,
&signing_key,
)?;
pack.save(&output)?;
info!("Policy exported to {}", output.display());
Ok(())
}
/// Statistics returned from policy import.
#[derive(Debug, Clone, Default)]
pub struct ImportStats {
/// Number of assertions imported.
pub assertions_imported: usize,
/// Number of aliases imported.
pub aliases_imported: usize,
/// Number of predicate alias sets imported.
pub predicate_aliases_imported: usize,
}
/// Statistics returned from policy re-signing.
#[derive(Debug, Clone, Default)]
pub struct ResignStats {
/// Number of assertions in the pack.
pub assertions_count: usize,
/// Number of aliases in the pack.
pub aliases_count: usize,
/// Length of the signature chain (audit trail).
pub signature_chain_length: usize,
}
/// Import a Trust Pack into the local Episteme.
///
/// Loads and verifies the pack, then imports assertions and aliases
/// into the local storage.
#[instrument(skip(config), fields(file = %file.display()))]
pub async fn import_policy(
file: PathBuf,
config: &AphoriaConfig,
) -> Result<ImportStats, AphoriaError> {
use stemedb_storage::{AliasStore, PackSourceInfo, PackSourceStore, PredicateIndexStore};
info!(file = %file.display(), "Importing policy");
// Load and verify the pack
let pack = TrustPack::load(&file)?;
info!(
name = %pack.header.name,
version = %pack.header.version,
assertions = pack.assertions.len(),
aliases = pack.aliases.len(),
"Trust pack verified"
);
// Open local Episteme
let project_root = std::env::current_dir()?;
let mut episteme = LocalEpisteme::open(config, &project_root).await?;
let mut stats = ImportStats::default();
// Import assertions via ingest_authoritative
if !pack.assertions.is_empty() {
let ingested = episteme.ingest_authoritative(&pack.assertions).await?;
stats.assertions_imported = ingested;
// Build pack source info for attribution
let pack_info = PackSourceInfo {
pack_name: pack.header.name.clone(),
pack_version: pack.header.version.clone(),
issuer_hex: hex::encode(&pack.header.issuer_id[..4]),
};
// Update predicate indexes and store pack source for all assertions
// This is needed because ingest_authoritative goes through the WAL,
// which doesn't update these indexes directly
let predicate_store =
stemedb_storage::GenericPredicateIndexStore::new(episteme.store().clone());
for assertion in &pack.assertions {
// Compute hash same way as ingestion
let bytes = stemedb_core::serde::serialize(assertion)
.map_err(|e| AphoriaError::Storage(e.to_string()))?;
let hash = *blake3::hash(&bytes).as_bytes();
// Store pack source for policy attribution
if let Err(e) =
episteme.pack_source_store().set_pack_source(&assertion.subject, &pack_info).await
{
warn!(
subject = %assertion.subject,
error = %e,
"Failed to store pack source"
);
}
// Add ALL imported assertions to "authoritative" index for conflict detection
if let Err(e) =
predicate_store.add_to_predicate_index(predicates::AUTHORITATIVE, &hash).await
{
warn!(
subject = %assertion.subject,
error = %e,
"Failed to add to authoritative index"
);
}
// Also add to "acknowledged" index if applicable
if assertion.predicate == predicates::ACKNOWLEDGED {
if let Err(e) =
predicate_store.add_to_predicate_index(predicates::ACKNOWLEDGED, &hash).await
{
warn!(
subject = %assertion.subject,
error = %e,
"Failed to add to acknowledged index"
);
}
}
}
}
// Import aliases
for alias in &pack.aliases {
let alias_store = stemedb_storage::GenericAliasStore::new(episteme.store().clone());
alias_store.set_alias(alias).await.map_err(|e| AphoriaError::Storage(e.to_string()))?;
stats.aliases_imported += 1;
}
// Log predicate aliases (they're stored with the pack, not separately)
if !pack.predicate_aliases.is_empty() {
info!(count = pack.predicate_aliases.len(), "Pack includes predicate alias sets");
stats.predicate_aliases_imported = pack.predicate_aliases.len();
}
episteme.shutdown().await;
info!(
assertions = stats.assertions_imported,
aliases = stats.aliases_imported,
"Policy imported successfully"
);
Ok(stats)
}
/// Acknowledge a conflict as intentional.
///
/// Creates an assertion in Episteme recording that this conflict has been
/// reviewed and accepted. The conflict still appears in reports but marked as ACK.
#[instrument(skip(config), fields(concept_path = %args.concept_path))]
pub async fn acknowledge(
args: AcknowledgeArgs,
config: &AphoriaConfig,
) -> Result<(), AphoriaError> {
info!("Acknowledging conflict");
let project_root = std::env::current_dir()?;
let mut episteme = LocalEpisteme::open(config, &project_root).await?;
// Create acknowledgment assertion
let claim = ExtractedClaim {
concept_path: args.concept_path.clone(),
predicate: predicates::ACKNOWLEDGED.to_string(),
value: stemedb_core::types::ObjectValue::Text(args.reason.clone()),
file: "aphoria_ack".to_string(),
line: 0,
matched_text: format!("Acknowledged: {}", args.reason),
confidence: 1.0,
description: format!("Conflict acknowledged: {}", args.reason),
};
episteme.ingest_claims(&[claim]).await?;
episteme.shutdown().await;
Ok(())
}
/// Arguments for the bless command.
pub use crate::types::BlessArgs;
/// Bless a code pattern as the authoritative standard.
///
/// Unlike `acknowledge` (which creates a suppression with predicate="acknowledged"),
/// `bless` creates an assertion with the actual predicate and value that becomes
/// the authoritative standard. Blessed patterns can be exported as Trust Packs
/// and imported into other projects.
///
/// # Example
///
/// ```ignore
/// // Bless TLS as required
/// bless(BlessArgs {
/// concept_path: "code://rust/grpc/tls".to_string(),
/// predicate: "enabled".to_string(),
/// value: "true".to_string(),
/// reason: "All services MUST use mTLS".to_string(),
/// }, &config).await?;
/// ```
#[instrument(skip(config), fields(concept_path = %args.concept_path, predicate = %args.predicate))]
pub async fn bless(args: BlessArgs, config: &AphoriaConfig) -> Result<(), AphoriaError> {
info!("Blessing code pattern as standard");
let project_root = std::env::current_dir()?;
let mut episteme = LocalEpisteme::open(config, &project_root).await?;
// Parse the value string into ObjectValue
let value = parse_value(&args.value);
// Create the blessed assertion with the actual predicate (not "acknowledged")
let claim = ExtractedClaim {
concept_path: args.concept_path.clone(),
predicate: args.predicate.clone(), // The actual predicate, not "acknowledged"
value,
file: "aphoria_bless".to_string(),
line: 0,
matched_text: format!("Blessed: {} = {}", args.predicate, args.value),
confidence: 1.0,
description: args.reason.clone(),
};
episteme.ingest_claims(&[claim]).await?;
episteme.shutdown().await;
info!(concept_path = %args.concept_path, predicate = %args.predicate, "Pattern blessed as standard");
Ok(())
}
/// Record an intentional policy change.
///
/// Unlike `acknowledge()` which marks a conflict as reviewed,
/// `update()` records a new baseline value for a concept. Use this
/// when you intentionally change a configuration and want future
/// scans to recognize this as the expected value.
///
/// # Example
///
/// ```ignore
/// // Record intentional change to pool size
/// update(UpdateArgs {
/// concept_path: "db/pool_size".to_string(),
/// value: "100".to_string(),
/// reason: "Scaling for Black Friday".to_string(),
/// }, &config).await?;
/// ```
#[instrument(skip(config), fields(concept_path = %args.concept_path))]
pub async fn update(args: UpdateArgs, config: &AphoriaConfig) -> Result<(), AphoriaError> {
info!("Recording policy update");
let project_root = std::env::current_dir()?;
let mut episteme = LocalEpisteme::open(config, &project_root).await?;
// Parse the value string into ObjectValue
let value = parse_value(&args.value);
// Create policy update assertion
let claim = ExtractedClaim {
concept_path: args.concept_path.clone(),
predicate: predicates::POLICY_UPDATE.to_string(),
value,
file: "aphoria_update".to_string(),
line: 0,
matched_text: format!("Policy update: {} = {}", args.concept_path, args.value),
confidence: 1.0,
description: format!("Intentional change: {}", args.reason),
};
episteme.ingest_claims(&[claim]).await?;
episteme.shutdown().await;
info!(
concept_path = %args.concept_path,
value = %args.value,
"Policy update recorded"
);
Ok(())
}
/// Re-sign a Trust Pack with a new key.
///
/// Loads an existing pack (without verifying the old signature), re-signs with
/// a new key, and optionally preserves the signature chain for audit trail.
///
/// # Arguments
///
/// * `file` - Path to the existing .pack file
/// * `output` - Path for the re-signed pack
/// * `key_path` - Optional path to the new signing key (defaults to .aphoria/agent.key)
/// * `reason` - Optional reason for re-signing (e.g., "Key rotation", "Security incident")
/// * `chain_signatures` - Whether to preserve the signature chain for audit trail
///
/// # Example
///
/// ```ignore
/// // Re-sign with new key, preserving audit trail
/// resign_policy(
/// "old.pack".into(),
/// "new.pack".into(),
/// None, // Use default key
/// Some("Annual key rotation".to_string()),
/// true, // Preserve chain
/// ).await?;
/// ```
#[instrument(skip_all, fields(file = %file.display(), output = %output.display()))]
pub async fn resign_policy(
file: PathBuf,
output: PathBuf,
key_path: Option<PathBuf>,
reason: Option<String>,
chain_signatures: bool,
) -> Result<ResignStats, AphoriaError> {
// 1. Load existing pack (skip verification - key may have changed)
let pack = TrustPack::load_unverified(&file)?;
info!(
name = %pack.header.name,
version = %pack.header.version,
assertions = pack.assertions.len(),
"Loaded pack for re-signing"
);
// 2. Load new signing key
let project_root = std::env::current_dir()?;
let key_file = key_path.unwrap_or_else(|| project_root.join(".aphoria/agent.key"));
let signing_key = bridge::load_key_from_file(&key_file)?;
// 3. Build signature chain (audit trail)
let mut chain = pack.signature_chain.clone();
if chain_signatures {
chain.push(SignatureRecord {
issuer_id: pack.header.issuer_id,
signature: pack.signature,
signed_at: pack.header.timestamp,
reason,
});
info!(chain_length = chain.len(), "Preserving signature chain for audit");
}
// 4. Create new pack with updated signature
let new_pack = TrustPack::resign(
pack.header.name.clone(),
pack.header.version.clone(),
pack.assertions.clone(),
pack.aliases.clone(),
pack.predicate_aliases.clone(),
&signing_key,
chain.clone(),
)?;
// 5. Save to output
new_pack.save(&output)?;
info!(
output = %output.display(),
"Re-signed pack saved"
);
Ok(ResignStats {
assertions_count: new_pack.assertions.len(),
aliases_count: new_pack.aliases.len(),
signature_chain_length: new_pack.signature_chain.len(),
})
}
/// Parse a string value into an ObjectValue.
///
/// Supports:
/// - "true"/"false" → Boolean
/// - Finite numeric strings → Number (rejects NaN/Infinity)
/// - Everything else → Text
pub fn parse_value(s: &str) -> stemedb_core::types::ObjectValue {
use stemedb_core::types::ObjectValue;
match s.to_lowercase().as_str() {
"true" => ObjectValue::Boolean(true),
"false" => ObjectValue::Boolean(false),
_ => {
// Try to parse as number, but reject NaN and Infinity
// as they're likely unintended and could cause issues downstream
if let Ok(n) = s.parse::<f64>() {
if n.is_finite() {
ObjectValue::Number(n)
} else {
ObjectValue::Text(s.to_string())
}
} else {
ObjectValue::Text(s.to_string())
}
}
}
}