use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use tsafe_core::attest_contract::AttestContract;
use tsafe_core::run_evidence::{
ContractRef, DeniedSensitiveEnvEvidence, EnforcementResult, EnvironmentEvidence,
InjectedSecretEvidence, MachineEvidence, ProcessEvidence, RiskDelta, RunEvidence,
RUN_EVIDENCE_VERSION, RUN_SCHEMA,
};
use crate::event_log::{enforce_completed_event, EventLog};
use crate::hash::blake3_hash;
use crate::model::ScanReport;
use crate::redact;
use crate::scan;
pub struct EnforceOptions {
pub contract_path: PathBuf,
pub output_path: PathBuf,
pub audit_events_path: PathBuf,
pub command: Vec<String>,
pub allow_command_override: bool,
}
pub fn enforce(options: EnforceOptions) -> Result<(RunEvidence, i32)> {
if options.command.is_empty() {
bail!("invalid CLI usage: enforce requires a command after --");
}
let contract_bytes = fs::read(&options.contract_path)
.with_context(|| format!("read contract: {}", options.contract_path.display()))?;
let contract_hash = blake3_hash(&contract_bytes);
let contract: AttestContract =
serde_json::from_slice(&contract_bytes).context("parse attest contract")?;
contract
.ensure_valid()
.map_err(|errors| anyhow::anyhow!("contract violation: {errors}"))?;
if contract.command != options.command && !options.allow_command_override {
bail!("contract violation: supplied command does not match contract command");
}
let started_at = Utc::now();
let parent_env: BTreeMap<String, String> = std::env::vars().collect();
let mut child_env = BTreeMap::new();
let mut safe_baseline_injected = Vec::new();
if contract.policy.allow_safe_baseline {
for name in &contract.safe_baseline_env {
if let Some(value) = parent_env.get(name) {
child_env.insert(name.clone(), value.clone());
safe_baseline_injected.push(name.clone());
}
}
}
let mut secrets_injected = Vec::new();
let mut violations = Vec::new();
for allowed in &contract.allowed_env {
match resolve_source(&allowed.source, &allowed.name, &parent_env) {
Ok(Some(value)) => {
child_env.insert(allowed.name.clone(), value.clone());
secrets_injected.push(InjectedSecretEvidence {
name: allowed.name.clone(),
source: allowed.source.clone(),
hash: blake3_hash(&value),
redacted_value: redact::redacted(&value),
required: allowed.required,
});
}
Ok(None) if allowed.required => {
violations.push(format!(
"required allowed_env {} has no resolvable source {}",
allowed.name, allowed.source
));
}
Ok(None) => {}
Err(error) => violations.push(error),
}
}
if !violations.is_empty() {
bail!("contract violation: {}", violations.join("; "));
}
let child_names: BTreeSet<String> = child_env.keys().cloned().collect();
let allowed_names: BTreeSet<String> = contract
.allowed_env
.iter()
.map(|env| env.name.clone())
.collect();
let sensitive_ambient_inherited_count = parent_env
.keys()
.filter(|name| scan::is_sensitive_env_name(name))
.filter(|name| child_names.contains(*name))
.filter(|name| !allowed_names.contains(*name))
.count();
let contract_denied: BTreeMap<String, String> = contract
.denied_env
.iter()
.map(|env| (env.name.clone(), env.reason.clone()))
.collect();
let sensitive_env_denied = parent_env
.iter()
.filter(|(name, _)| scan::is_sensitive_env_name(name) && !child_names.contains(*name))
.map(|(name, value)| DeniedSensitiveEnvEvidence {
name: name.clone(),
hash: blake3_hash(value),
reason: contract_denied
.get(name)
.cloned()
.unwrap_or_else(|| "Not declared in contract".to_string()),
})
.collect::<Vec<_>>();
let cwd = PathBuf::from(&contract.repo_path);
ensure_command_resolves(&options.command[0], &cwd, &child_env)?;
println!("tsafe attest run โ enforcement active");
println!("Command:");
println!(" {}", options.command.join(" "));
println!("Environment:");
println!(" Parent env: {} vars", parent_env.len());
println!(" Child env: {} vars", child_env.len());
println!(
" Removed: {} vars",
parent_env.len().saturating_sub(child_env.len())
);
println!(" Sensitive ambient vars inherited: {sensitive_ambient_inherited_count}");
println!(
" environment authority exposure after enforcement: {}",
environment_authority_exposure(sensitive_ambient_inherited_count)
);
println!("Injected:");
for injected in &secrets_injected {
println!(
" {:<24} {}",
injected.name,
redact::short_hash(&injected.hash)
);
}
println!("Denied:");
for denied in terminal_denied_env(&sensitive_env_denied) {
println!(" {}", denied.name);
}
let start = Instant::now();
let mut child = Command::new(&options.command[0])
.args(&options.command[1..])
.current_dir(&cwd)
.env_clear()
.envs(&child_env)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("spawn command: {}", options.command.join(" ")))?;
let pid = child.id();
let status = child.wait().context("wait for child process")?;
let duration_ms = start.elapsed().as_millis();
let exit_code = status.code().unwrap_or(1);
let finished_at = Utc::now();
println!("Process exited: {exit_code}");
println!("Evidence written: {}", options.output_path.display());
let before_score = load_before_score(&contract).unwrap_or(0);
let after_score = after_score(
before_score,
parent_env.len().saturating_sub(child_env.len()),
&contract,
&secrets_injected,
&sensitive_env_denied,
);
let evidence = RunEvidence {
schema: RUN_SCHEMA.to_string(),
tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
started_at,
finished_at,
repo_path: contract.repo_path.clone(),
repo_commit: contract.repo_commit.clone(),
command: options.command,
contract: ContractRef {
path: options.contract_path.display().to_string(),
hash: contract_hash,
},
environment: EnvironmentEvidence {
parent_env_count: parent_env.len(),
child_env_count: child_env.len(),
removed_env_count: parent_env.len().saturating_sub(child_env.len()),
safe_baseline_injected,
secrets_injected,
sensitive_env_denied,
},
process: ProcessEvidence {
pid,
exit_code,
duration_ms,
cwd: cwd.display().to_string(),
},
machine: MachineEvidence {
hostname_hash: blake3_hash(
hostname::get()
.ok()
.and_then(|host| host.into_string().ok())
.unwrap_or_else(|| "unknown-host".to_string()),
),
username_hash: blake3_hash(whoami::username()),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
},
result: EnforcementResult {
contract_enforced: true,
violations: Vec::new(),
risk_delta: RiskDelta {
before_score,
after_score,
},
},
signature: None,
};
evidence
.ensure_valid()
.map_err(|errors| anyhow::anyhow!("generated invalid run evidence: {errors}"))?;
write_run(&evidence, &options.output_path)?;
let event = enforce_completed_event(&evidence, &options.output_path)?;
EventLog::new(&options.audit_events_path).append(&event)?;
println!(
"Audit event written: {}",
options.audit_events_path.display()
);
Ok((evidence, exit_code))
}
fn environment_authority_exposure(sensitive_ambient_inherited_count: usize) -> &'static str {
if sensitive_ambient_inherited_count == 0 {
"safe baseline + contract-declared variables only"
} else {
"safe baseline included sensitive ambient variables"
}
}
fn terminal_denied_env(denied: &[DeniedSensitiveEnvEvidence]) -> Vec<&DeniedSensitiveEnvEvidence> {
let preferred_demo_names = ["AWS_SECRET_ACCESS_KEY", "GH_TOKEN"];
let preferred = preferred_demo_names
.iter()
.filter_map(|name| denied.iter().find(|item| item.name == *name))
.collect::<Vec<_>>();
if preferred.is_empty() {
denied.iter().collect()
} else {
preferred
}
}
fn ensure_command_resolves(
command: &str,
cwd: &Path,
child_env: &BTreeMap<String, String>,
) -> Result<()> {
let path = child_env.get("PATH").map(String::as_str);
which::which_in(command, path, cwd)
.map(|_| ())
.with_context(|| format!("contract violation: command executable not found: {command}"))
}
pub fn write_run(evidence: &RunEvidence, output: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(evidence)?;
ensure_parent_dir(output)?;
fs::write(output, json).with_context(|| format!("write run evidence: {}", output.display()))
}
fn resolve_source(
source: &str,
allowed_name: &str,
parent_env: &BTreeMap<String, String>,
) -> std::result::Result<Option<String>, String> {
if let Some(name) = source.strip_prefix("literal://demo/") {
return Ok(Some(demo_value(if name.is_empty() {
allowed_name
} else {
name
})));
}
if let Some(name) = source.strip_prefix("env://") {
return Ok(parent_env.get(name).cloned());
}
Err(format!(
"unsupported source for {allowed_name}: {source}; MVP supports literal://demo/* and env://*"
))
}
fn demo_value(name: &str) -> String {
match name {
"DATABASE_URL" => "postgres://tsafe-demo:redacted@localhost:5432/demo".to_string(),
"API_TOKEN" => "tsafe_demo_token_1234567890".to_string(),
other => format!("tsafe_demo_{}_1234567890", other.to_ascii_lowercase()),
}
}
fn load_before_score(contract: &AttestContract) -> Option<u32> {
let scan_path = contract.source_scan.as_ref()?;
let json = fs::read_to_string(scan_path).ok()?;
let report = serde_json::from_str::<ScanReport>(&json).ok()?;
Some(report.summary.risk_score)
}
fn after_score(
before_score: u32,
removed_env_count: usize,
contract: &AttestContract,
injected: &[InjectedSecretEvidence],
denied: &[DeniedSensitiveEnvEvidence],
) -> u32 {
let mut score = before_score as i32;
if removed_env_count > 0 {
score -= 15;
}
if contract
.allowed_env
.iter()
.filter(|env| env.required)
.all(|required| injected.iter().any(|item| item.name == required.name))
{
score -= 10;
}
if !denied.is_empty() {
score -= 10;
}
score.max(0) as u32
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("create output directory: {}", parent.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tsafe_core::attest_contract::{
AttestAllowedEnv, AttestContract, AttestContractPolicy, AttestDeniedEnv,
ATTEST_CONTRACT_SCHEMA, REDACTION_BLAKE3,
};
#[test]
fn resolves_demo_sources() {
let value = resolve_source(
"literal://demo/DATABASE_URL",
"DATABASE_URL",
&BTreeMap::new(),
)
.unwrap()
.unwrap();
assert!(value.starts_with("postgres://"));
}
#[test]
fn reports_missing_required_env_source() {
let value = resolve_source(
"env://TSAFE_TEST_MISSING",
"TSAFE_TEST_MISSING",
&BTreeMap::new(),
)
.unwrap();
assert!(value.is_none());
}
#[test]
fn computes_lower_after_score_when_authority_removed() {
let contract = AttestContract {
schema: ATTEST_CONTRACT_SCHEMA.to_string(),
repo_path: ".".to_string(),
repo_commit: None,
created_at: Utc::now(),
created_by: "test".to_string(),
command: vec!["true".to_string()],
policy: AttestContractPolicy {
default_env: "deny".to_string(),
allow_safe_baseline: true,
redaction: REDACTION_BLAKE3.to_string(),
},
allowed_env: vec![AttestAllowedEnv {
name: "DATABASE_URL".to_string(),
source: "literal://demo/DATABASE_URL".to_string(),
required: true,
reason: "test".to_string(),
}],
denied_env: vec![AttestDeniedEnv {
name: "AWS_SECRET_ACCESS_KEY".to_string(),
reason: "test".to_string(),
}],
safe_baseline_env: vec![],
source_scan: None,
};
let after = after_score(
72,
10,
&contract,
&[InjectedSecretEvidence {
name: "DATABASE_URL".to_string(),
source: "literal://demo/DATABASE_URL".to_string(),
hash: blake3_hash("secret"),
redacted_value: redact::redacted("secret"),
required: true,
}],
&[DeniedSensitiveEnvEvidence {
name: "AWS_SECRET_ACCESS_KEY".to_string(),
hash: blake3_hash("secret"),
reason: "test".to_string(),
}],
);
assert!(after < 72);
}
}