File size: 10,763 Bytes
9425aed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
use anyhow::Result;
use blake3;
use chrono::Utc;
use ed25519_dalek::SigningKey;
use git2::{Repository, Signature as GitSignature};
use hex;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, info, warn};

/// Cryptographic approval certificate
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalCertificate {
    /// Change ID being approved
    pub change_id: String,
    /// Human reviewer's name
    pub reviewer: String,
    /// ISO8601 timestamp of approval
    pub approval_time: String,
    /// Blake3 hash of the change evidence
    pub evidence_hash: String,
    /// Ed25519 signature of (change_id || reviewer || time)
    pub signature: String,
}

/// Commit metadata including human approval
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanApprovedCommit {
    /// Change ID
    pub change_id: String,
    /// Human reviewer who approved
    pub approved_by: String,
    /// Approval timestamp
    pub approval_date: String,
    /// URL or link to evidence
    pub evidence_url: String,
    /// Link to review decision
    pub review_link: Option<String>,
}

/// Commit gateway enforcing human approval
#[derive(Clone)]
pub struct CommitGateway {
    repo_path: PathBuf,
    approval_timeout_secs: u64,
    /// Signing key for certificates (in production, would be secured)
    signing_key: Arc<Option<SigningKey>>,
}

impl CommitGateway {
    /// Create a new commit gateway
    pub fn new(repo_path: PathBuf, approval_timeout_secs: u64) -> Result<Self> {
        info!(
            "πŸ” CommitGateway initialized (repo: {:?}, timeout: {}s)",
            repo_path, approval_timeout_secs
        );

        // In production: load signing key from secure storage
        // For now: use a placeholder
        let key_seed = [0u8; 32];
        let signing_key = SigningKey::from_bytes(&key_seed);

        Ok(CommitGateway {
            repo_path,
            approval_timeout_secs,
            signing_key: Arc::new(Some(signing_key)),
        })
    }

    /// Pre-commit verification: ensure change has human approval
    pub async fn verify_approval_required(&self, change_id: &str) -> Result<()> {
        info!("πŸ” Verifying approval requirement for change: {}", change_id);

        // This would check the audit log to ensure approval exists
        // For now: placeholder verification
        if change_id.is_empty() {
            return Err(anyhow::anyhow!("Change ID cannot be empty"));
        }

        debug!("βœ… Approval verification passed for: {}", change_id);
        Ok(())
    }

    /// Stage files for commit
    pub fn stage_files(&self, files: &[String]) -> Result<()> {
        let repo = Repository::open(&self.repo_path)?;
        let mut index = repo.index()?;

        for file in files {
            index.add_path(&std::path::Path::new(file))?;
        }

        info!("πŸ“¦ Staged {} files for commit", files.len());
        index.write()?;

        Ok(())
    }

    /// Create an approval certificate
    pub fn create_approval_certificate(
        &self,
        change_id: &str,
        reviewer: &str,
        evidence_url: &str,
    ) -> Result<ApprovalCertificate> {
        let now = Utc::now();

        // Hash the evidence URL
        let evidence_hash = blake3::hash(evidence_url.as_bytes());
        let evidence_hash_hex = hex::encode(evidence_hash.as_bytes());

        // Create signing material: change_id || reviewer || timestamp
        let signing_material = format!(
            "{}||{}||{}",
            change_id,
            reviewer,
            now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
        );

        // Sign (in production: use actual signing key, not placeholder)
        let signature_bytes = blake3::hash(signing_material.as_bytes());
        let signature_hex = hex::encode(signature_bytes.as_bytes());

        let cert = ApprovalCertificate {
            change_id: change_id.to_string(),
            reviewer: reviewer.to_string(),
            approval_time: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
            evidence_hash: evidence_hash_hex,
            signature: signature_hex,
        };

        info!("πŸŽ–οΈ  Approval certificate created: {:?}", cert);

        Ok(cert)
    }

    /// Commit changes with human approval metadata
    pub fn commit_with_approval(
        &self,
        change_id: &str,
        reviewer: &str,
        message: &str,
        evidence_url: &str,
    ) -> Result<String> {
        let repo = Repository::open(&self.repo_path)?;

        // Get the index (staged files)
        let mut index = repo.index()?;
        let tree_id = index.write_tree()?;
        let tree = repo.find_tree(tree_id)?;

        // Create git signature for the commit
        let git_sig = GitSignature::now(reviewer, &format!("{}-review@snapkitty.ai", reviewer))?;

        // Get HEAD commit (parent)
        let head = repo.head()?;
        let parent_commit = repo.find_commit(head.target().ok_or(anyhow::anyhow!(
            "No HEAD commit found"
        ))?)?;

        // Format commit message with approval metadata
        let commit_body = format!(
            "{}\n\nApproved-By: {}\nReview-Date: {}\nEvidence: {}\nChange-ID: {}\n\nCo-Authored-By: Human-Touch Gateway <human-review@snapkitty.ai>",
            message,
            reviewer,
            Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
            evidence_url,
            change_id
        );

        // Create the commit
        let commit_oid = repo.commit(
            Some("HEAD"),
            &git_sig,
            &git_sig,
            &commit_body,
            &tree,
            &[&parent_commit],
        )?;

        let commit_hash = commit_oid.to_string();
        info!(
            "βœ… Commit created: {} (change: {}, reviewer: {})",
            commit_hash, change_id, reviewer
        );

        Ok(commit_hash)
    }

    /// Reject a commit (prevent it from landing)
    pub fn reject_commit(&self, change_id: &str, reason: &str) -> Result<()> {
        warn!(
            "❌ Commit rejected for change: {} β€” Reason: {}",
            change_id, reason
        );

        // Would write rejection to audit log
        // Prevent any git operations for this change

        Ok(())
    }

    /// Verify commit signature and metadata
    pub fn verify_commit_approval(&self, commit_hash: &str) -> Result<HumanApprovedCommit> {
        let repo = Repository::open(&self.repo_path)?;
        let oid = git2::Oid::from_str(commit_hash)?;
        let commit = repo.find_commit(oid)?;

        let message = commit.message().unwrap_or("(no message)");

        // Parse approval metadata from commit message
        let mut approved_by = "unknown";
        let mut approval_date = "unknown";
        let mut evidence_url = "unknown";
        let mut change_id = "unknown";

        for line in message.lines() {
            if line.starts_with("Approved-By:") {
                approved_by = line.trim_start_matches("Approved-By:").trim();
            } else if line.starts_with("Review-Date:") {
                approval_date = line.trim_start_matches("Review-Date:").trim();
            } else if line.starts_with("Evidence:") {
                evidence_url = line.trim_start_matches("Evidence:").trim();
            } else if line.starts_with("Change-ID:") {
                change_id = line.trim_start_matches("Change-ID:").trim();
            }
        }

        // Verify all required fields are present
        if approved_by == "unknown" {
            return Err(anyhow::anyhow!("Commit missing Approved-By field"));
        }

        debug!(
            "βœ… Commit verified: {} approved by {} on {}",
            commit_hash, approved_by, approval_date
        );

        Ok(HumanApprovedCommit {
            change_id: change_id.to_string(),
            approved_by: approved_by.to_string(),
            approval_date: approval_date.to_string(),
            evidence_url: evidence_url.to_string(),
            review_link: None,
        })
    }

    /// Enforce pre-commit hook: no auto-commits allowed
    pub fn check_no_auto_commit(&self, message: &str) -> Result<()> {
        // Reject auto-generated commits
        if message.contains("[auto]") || message.contains("auto-commit") {
            return Err(anyhow::anyhow!(
                "❌ Auto-commits rejected. All changes require human approval."
            ));
        }

        // Reject empty messages
        if message.trim().is_empty() {
            return Err(anyhow::anyhow!("❌ Commit message cannot be empty"));
        }

        // Require Approved-By field
        if !message.contains("Approved-By:") {
            return Err(anyhow::anyhow!(
                "❌ Commit missing Approved-By field. All commits require human approval."
            ));
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_verify_approval_required() {
        let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();
        assert!(gateway.verify_approval_required("test-123").await.is_ok());
        assert!(gateway.verify_approval_required("").await.is_err());
    }

    #[test]
    fn test_create_approval_certificate() {
        let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();
        let cert = gateway
            .create_approval_certificate(
                "change-123",
                "reviewer@example.com",
                "https://example.com/evidence",
            )
            .unwrap();

        assert_eq!(cert.change_id, "change-123");
        assert_eq!(cert.reviewer, "reviewer@example.com");
        assert!(!cert.signature.is_empty());
    }

    #[test]
    fn test_check_no_auto_commit() {
        let gateway = CommitGateway::new(PathBuf::from("."), 3600).unwrap();

        // Should reject auto-commits
        assert!(gateway
            .check_no_auto_commit("feat: [auto] add feature")
            .is_err());

        // Should reject empty
        assert!(gateway.check_no_auto_commit("").is_err());

        // Should require Approved-By
        assert!(gateway.check_no_auto_commit("feat: add feature").is_err());

        // Should accept valid message with approval
        assert!(gateway
            .check_no_auto_commit("feat: add feature\n\nApproved-By: Human")
            .is_ok());
    }
}