use anyhow::Result;
use clap::{Args, Subcommand};
use localgpt::config::Config;
use localgpt::security;
#[derive(Args)]
pub struct MdArgs {
#[command(subcommand)]
pub command: MdCommands,
}
#[derive(Subcommand)]
pub enum MdCommands {
Sign,
Verify,
Audit {
#[arg(long)]
json: bool,
#[arg(long)]
filter: Option<String>,
},
Status,
}
pub async fn run(args: MdArgs) -> Result<()> {
match args.command {
MdCommands::Sign => sign_policy().await,
MdCommands::Verify => verify_policy().await,
MdCommands::Audit { json, filter } => show_audit(json, filter).await,
MdCommands::Status => show_status().await,
}
}
async fn sign_policy() -> Result<()> {
let config = Config::load()?;
let workspace = config.workspace_path();
let state_dir = workspace
.parent()
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
security::ensure_device_key(state_dir)?;
let policy_path = workspace.join(security::POLICY_FILENAME);
if !policy_path.exists() {
anyhow::bail!(
"No {} found at {}. Create it first.",
security::POLICY_FILENAME,
policy_path.display()
);
}
let manifest = security::sign_policy(state_dir, &workspace, "cli")?;
security::append_audit_entry(
state_dir,
security::AuditAction::Signed,
&manifest.content_sha256,
"cli",
)?;
println!(
"Signed {} (sha256: {} | hmac: {})",
security::POLICY_FILENAME,
&manifest.content_sha256[..16],
&manifest.hmac_sha256[..16]
);
Ok(())
}
async fn verify_policy() -> Result<()> {
let config = Config::load()?;
let workspace = config.workspace_path();
let state_dir = workspace
.parent()
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
let result = security::load_and_verify_policy(&workspace, state_dir);
match result {
security::PolicyVerification::Valid(content) => {
let char_count = content.len();
println!("Policy: VALID ({} chars)", char_count);
let sha = security::content_sha256(&content);
security::append_audit_entry(state_dir, security::AuditAction::Verified, &sha, "cli")?;
}
security::PolicyVerification::Unsigned => {
println!("Policy: UNSIGNED");
println!(" Run `localgpt md sign` to activate.");
}
security::PolicyVerification::TamperDetected => {
println!("Policy: TAMPER DETECTED");
println!(" The file was modified after signing. Re-sign with `localgpt md sign`.");
security::append_audit_entry(
state_dir,
security::AuditAction::TamperDetected,
"",
"cli",
)?;
}
security::PolicyVerification::Missing => {
println!("Policy: MISSING");
println!(
" No {} found. Using hardcoded security only.",
security::POLICY_FILENAME
);
}
security::PolicyVerification::ManifestCorrupted => {
println!("Policy: MANIFEST CORRUPTED");
println!(" Re-sign with `localgpt md sign`.");
}
security::PolicyVerification::SuspiciousContent(warnings) => {
println!("Policy: REJECTED (suspicious content)");
for w in &warnings {
println!(" - {}", w);
}
}
}
Ok(())
}
async fn show_audit(json_output: bool, filter: Option<String>) -> Result<()> {
let config = Config::load()?;
let workspace = config.workspace_path();
let state_dir = workspace
.parent()
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
let mut entries = security::read_audit_log(state_dir)?;
if let Some(ref filter_action) = filter {
entries.retain(|e| {
let action_str = serde_json::to_string(&e.action).unwrap_or_default();
let action_str = action_str.trim_matches('"');
action_str == filter_action
});
}
if entries.is_empty() {
if filter.is_some() {
println!("No audit log entries matching filter.");
} else {
println!("No audit log entries.");
}
return Ok(());
}
let broken = security::verify_audit_chain(state_dir)?;
if json_output {
let output = serde_json::to_string_pretty(&entries)?;
println!("{}", output);
} else {
let label = if let Some(ref f) = filter {
format!(
"Security Audit Log ({} entries, filter: {}):",
entries.len(),
f
)
} else {
format!("Security Audit Log ({} entries):", entries.len())
};
println!("{}", label);
println!();
for (i, entry) in entries.iter().enumerate() {
let chain_status = if broken.contains(&i) {
" [CHAIN BROKEN]"
} else {
""
};
let detail_str = entry
.detail
.as_deref()
.map(|d| format!(" — {}", d))
.unwrap_or_default();
println!(
" {} {:?} (source: {}, sha256: {}){}{}",
entry.ts,
entry.action,
entry.source,
if entry.content_sha256.len() >= 16 {
&entry.content_sha256[..16]
} else {
&entry.content_sha256
},
chain_status,
detail_str,
);
}
println!();
if broken.is_empty() {
println!("Chain integrity: INTACT");
} else {
println!("Chain integrity: BROKEN at {} position(s)", broken.len());
}
}
Ok(())
}
async fn show_status() -> Result<()> {
let config = Config::load()?;
let workspace = config.workspace_path();
let state_dir = workspace
.parent()
.ok_or_else(|| anyhow::anyhow!("Workspace has no parent directory"))?;
println!("Security Status:");
let policy_path = workspace.join(security::POLICY_FILENAME);
if policy_path.exists() {
let result = security::load_and_verify_policy(&workspace, state_dir);
let status = match result {
security::PolicyVerification::Valid(_) => "Valid (signed and verified)",
security::PolicyVerification::Unsigned => "Unsigned (run `localgpt md sign`)",
security::PolicyVerification::TamperDetected => "TAMPER DETECTED",
security::PolicyVerification::Missing => "Missing",
security::PolicyVerification::ManifestCorrupted => "Manifest corrupted",
security::PolicyVerification::SuspiciousContent(_) => "Rejected (suspicious content)",
};
let signed_at = security::read_manifest(&workspace)
.ok()
.map(|m| m.signed_at)
.unwrap_or_else(|| "N/A".to_string());
println!(" Policy: {} (exists)", policy_path.display());
println!(" Signature: {} (signed: {})", status, signed_at);
} else {
println!(" Policy: Not created");
}
let key_path = state_dir.join(".device_key");
if key_path.exists() {
println!(" Device Key: Present");
} else {
println!(" Device Key: Missing (run `localgpt init`)");
}
let entries = security::read_audit_log(state_dir)?;
if entries.is_empty() {
println!(" Audit Log: Empty");
} else {
let broken = security::verify_audit_chain(state_dir)?;
let chain_status = if broken.is_empty() {
"chain intact"
} else {
"CHAIN BROKEN"
};
println!(" Audit Log: {} entries, {}", entries.len(), chain_status);
}
println!(
" Protected: {} workspace files, {} external paths",
security::PROTECTED_FILES.len(),
security::PROTECTED_EXTERNAL_PATHS.len()
);
Ok(())
}