tsafe-attest 1.1.0

Attestation pipeline for tsafe — secret scanner + env-injection contract + run-evidence harness (algol-merged)
Documentation
//! `tsafe attest plan` — derive an `AttestContract` from a scan report.
//!
//! # Provenance
//!
//! Lifted from `algol/src/plan.rs` @ commit `6956cfd347cd8ce492231ba5aaa4952227d72689`
//! (`6956cfd`, branch `master`, repo `0ryant/algol`). Re-licensed
//! `AGPL-3.0-or-later` per ec `draft-algol-into-tsafe-merge.md` +
//! Phase 4 plan + operator decision 2026-05-21 ("agreed").
//!
//! # Phase 4 changes
//!
//! - Emits canonical `tsafe.contract.v1` schema (legacy still accepted on parse).
//! - Redaction default is `blake3` (legacy `sha256` still accepted on parse).
//! - `created_by` is `tsafe attest plan`.
//! - Imports the contract / env-models from `tsafe_core::attest_contract`.

use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use chrono::Utc;
use tsafe_core::attest_contract::{
    is_supported_source_uri, AttestAllowedEnv, AttestContract, AttestContractPolicy,
    AttestDeniedEnv, ATTEST_CONTRACT_SCHEMA, REDACTION_BLAKE3,
};

use crate::model::{FindingKind, ScanReport};
use crate::scan;

const SAFE_BASELINE_ENV: &[&str] = &[
    "PATH", "HOME", "TMPDIR", "TEMP", "USER", "SHELL", "NODE_ENV", "CI",
];

pub struct PlanOptions {
    pub repo: PathBuf,
    pub scan_path: Option<PathBuf>,
    pub inline_sources: Vec<String>,
    pub sources_file: Option<PathBuf>,
    pub command: Vec<String>,
}

pub fn create_plan(options: PlanOptions) -> Result<AttestContract> {
    if options.command.is_empty() {
        bail!("plan requires a command after --");
    }

    let repo = fs::canonicalize(&options.repo)
        .with_context(|| format!("repo not found: {}", options.repo.display()))?;
    let scan_path = options
        .scan_path
        .clone()
        .unwrap_or_else(|| PathBuf::from("tsafe-scan.json"));
    let report = load_or_create_scan(&repo, &scan_path, options.scan_path.is_some())?;
    let source_map = load_source_map(&options.inline_sources, options.sources_file.as_deref())?;

    let mut observed: BTreeSet<String> = report
        .observed_env_reads
        .iter()
        .map(|read| read.name.clone())
        .collect();

    for name in source_map.keys() {
        observed.insert(name.clone());
    }

    let mut allowed_env = Vec::new();
    for name in observed {
        let explicitly_mapped = source_map.contains_key(&name);
        if scan::is_high_risk_env_name(&name) && !explicitly_mapped {
            continue;
        }

        let source = source_map
            .get(&name)
            .cloned()
            .unwrap_or_else(|| format!("literal://demo/{name}"));
        allowed_env.push(AttestAllowedEnv {
            required: is_required_by_default(&name),
            reason: observed_reason(&report, &name),
            name,
            source,
        });
    }

    let allowed_names: BTreeSet<String> = allowed_env.iter().map(|env| env.name.clone()).collect();
    let mut denied_reasons: BTreeMap<String, String> = BTreeMap::new();

    for (name, _) in std::env::vars() {
        if scan::is_sensitive_env_name(&name) && !allowed_names.contains(&name) {
            denied_reasons.insert(
                name,
                "Ambient sensitive variable not required by command".to_string(),
            );
        }
    }

    for finding in &report.findings {
        let Some(name) = &finding.name else {
            continue;
        };
        if allowed_names.contains(name) {
            continue;
        }
        if matches!(
            finding.kind,
            FindingKind::EnvFile | FindingKind::HardcodedSecret | FindingKind::CiSecretReference
        ) && scan::is_sensitive_env_name(name)
        {
            denied_reasons
                .entry(name.clone())
                .or_insert_with(|| "Secret-like finding not required by command".to_string());
        }
    }

    let denied_env = denied_reasons
        .into_iter()
        .map(|(name, reason)| AttestDeniedEnv { name, reason })
        .collect();

    let contract = AttestContract {
        schema: ATTEST_CONTRACT_SCHEMA.to_string(),
        repo_path: repo.display().to_string(),
        repo_commit: report.repo_commit.clone(),
        created_at: Utc::now(),
        created_by: "tsafe attest plan".to_string(),
        command: options.command,
        policy: AttestContractPolicy {
            default_env: "deny".to_string(),
            allow_safe_baseline: true,
            redaction: REDACTION_BLAKE3.to_string(),
        },
        allowed_env,
        denied_env,
        safe_baseline_env: SAFE_BASELINE_ENV
            .iter()
            .map(|name| (*name).to_string())
            .collect(),
        source_scan: Some(scan_path.display().to_string()),
    };
    contract
        .ensure_valid()
        .map_err(|errors| anyhow::anyhow!("plan produced invalid contract: {errors}"))?;
    Ok(contract)
}

pub fn write_contract(contract: &AttestContract, output: &Path) -> Result<()> {
    let json = serde_json::to_string_pretty(contract)?;
    ensure_parent_dir(output)?;
    fs::write(output, json).with_context(|| format!("write contract: {}", output.display()))
}

pub fn print_summary(contract: &AttestContract) {
    println!("tsafe attest plan — contract drafted");
    println!("Command:");
    println!("  {}", contract.command.join(" "));
    println!("Allowed by draft:");
    for env in &contract.allowed_env {
        println!("  {}", env.name);
    }
    println!("Denied by draft:");
    for env in &contract.denied_env {
        println!("  {}", env.name);
    }
    println!("Policy:");
    println!("  default_env: {}", contract.policy.default_env);
    println!("Note: MVP only enforces environment authority, not file-backed credentials.");
}

fn load_or_create_scan(repo: &Path, scan_path: &Path, explicit_scan: bool) -> Result<ScanReport> {
    if scan_path.exists() {
        let json = fs::read_to_string(scan_path)
            .with_context(|| format!("read scan report: {}", scan_path.display()))?;
        return serde_json::from_str(&json).context("parse scan report");
    }

    if explicit_scan {
        bail!("scan report does not exist: {}", scan_path.display());
    }

    let report = scan::scan_repo(repo)?;
    scan::write_scan(&report, scan_path)?;
    Ok(report)
}

fn load_source_map(inline: &[String], file: Option<&Path>) -> Result<BTreeMap<String, String>> {
    let mut map = BTreeMap::new();

    if let Some(file) = file {
        let json = fs::read_to_string(file)
            .with_context(|| format!("read sources: {}", file.display()))?;
        map.extend(serde_json::from_str::<BTreeMap<String, String>>(&json)?);
    }

    for mapping in inline {
        let Some((name, source)) = mapping.split_once('=') else {
            bail!("invalid --source mapping, expected NAME=source://value: {mapping}");
        };
        map.insert(name.to_string(), source.to_string());
    }

    for (name, source) in &map {
        if name.trim().is_empty() {
            bail!("invalid --source mapping: env name must not be empty");
        }
        if !is_supported_source_uri(source) {
            bail!(
                "invalid --source mapping for {name}: unsupported source {source}; MVP supports literal://demo/* and env://*"
            );
        }
    }

    Ok(map)
}

fn observed_reason(report: &ScanReport, name: &str) -> String {
    report
        .observed_env_reads
        .iter()
        .find(|read| read.name == name)
        .map(|read| format!("Observed read in {}", read.file))
        .unwrap_or_else(|| "Explicit source mapping supplied".to_string())
}

fn is_required_by_default(name: &str) -> bool {
    matches!(name, "DATABASE_URL") || name.ends_with("_URL")
}

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 crate::model::{ObservedEnvRead, ScanReport, ScanSummary, SCAN_SCHEMA};

    #[test]
    fn creates_default_deny_contract() {
        let tmp = tempfile::tempdir().unwrap();
        let scan_path = tmp.path().join("scan.json");
        let report = ScanReport {
            schema: SCAN_SCHEMA.to_string(),
            repo_path: tmp.path().display().to_string(),
            repo_commit: None,
            scanned_at: chrono::Utc::now(),
            scanner_version: "test".to_string(),
            findings: vec![],
            observed_env_reads: vec![ObservedEnvRead {
                name: "DATABASE_URL".to_string(),
                file: "src/config.js".to_string(),
                line: 1,
                language: "javascript".to_string(),
                confidence: 0.9,
            }],
            ci_secret_references: vec![],
            summary: ScanSummary::default(),
        };
        fs::write(&scan_path, serde_json::to_string(&report).unwrap()).unwrap();

        let contract = create_plan(PlanOptions {
            repo: tmp.path().to_path_buf(),
            scan_path: Some(scan_path),
            inline_sources: vec![],
            sources_file: None,
            command: vec!["npm".to_string(), "test".to_string()],
        })
        .unwrap();

        assert_eq!(contract.policy.default_env, "deny");
        assert_eq!(contract.command, ["npm", "test"]);
        assert!(contract
            .allowed_env
            .iter()
            .any(|env| env.name == "DATABASE_URL"));
        assert!(contract.safe_baseline_env.contains(&"PATH".to_string()));
        assert_eq!(contract.policy.redaction, REDACTION_BLAKE3);
        assert_eq!(contract.schema, ATTEST_CONTRACT_SCHEMA);
    }
}