localgpt 0.1.3

A local device focused AI assistant with persistent markdown memory, autonomous heartbeat tasks, and semantic search. Single binary, no runtime dependencies.
Documentation
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! Append-only, hash-chained security audit log.
//!
//! Stored at `~/.localgpt/.security_audit.jsonl` (outside the workspace,
//! in the state directory). Each entry contains a SHA-256 hash of the
//! previous entry, forming a tamper-evident chain.
//!
//! # Format
//!
//! One JSON object per line (JSONL). Each entry includes:
//!
//! | Field | Description |
//! |-------|-------------|
//! | `ts` | ISO 8601 timestamp |
//! | `action` | What happened: `signed`, `verified`, `tamper_detected`, etc. |
//! | `content_sha256` | SHA-256 of the policy content at the time |
//! | `prev_entry_sha256` | SHA-256 of the previous JSONL line (chain link) |
//! | `source` | Who triggered it: `cli`, `gui`, or `session_start` |
//!
//! # Chain Integrity
//!
//! The first entry uses `000...000` (64 zeros) as `prev_entry_sha256`.
//! Every subsequent entry hashes the raw bytes of the previous line.
//! A broken chain indicates the log file was tampered with.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

const AUDIT_FILENAME: &str = ".security_audit.jsonl";

/// The hash used for the first entry in the chain (no predecessor).
const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";

/// Security audit log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
    /// ISO 8601 timestamp of the event.
    pub ts: String,
    /// What security action occurred.
    pub action: AuditAction,
    /// SHA-256 of the policy content at the time (hex-encoded).
    pub content_sha256: String,
    /// SHA-256 of the previous JSONL line (chain link, hex-encoded).
    pub prev_entry_sha256: String,
    /// Who triggered the action: `"cli"`, `"session_start"`, `"tool:{name}"`, etc.
    pub source: String,
    /// Optional context. Tool name and path for `WriteBlocked`, patterns for `SuspiciousContent`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// Security actions recorded in the audit log.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
    /// Policy template was created during workspace init.
    Created,
    /// Policy was signed via CLI or GUI.
    Signed,
    /// Policy was verified at session start.
    Verified,
    /// Tamper detected (HMAC mismatch or manifest corruption).
    TamperDetected,
    /// Policy file is missing from the workspace.
    Missing,
    /// Policy exists but has no manifest (not yet signed).
    Unsigned,
    /// Manifest JSON parse failure.
    ManifestCorrupted,
    /// Policy contains suspicious prompt injection patterns.
    SuspiciousContent,
    /// File watcher detected LocalGPT.md modification mid-session.
    FileChanged,
    /// Agent tool attempted to write a protected file.
    WriteBlocked,
    /// Previous audit entry corrupted, new chain segment started.
    ChainRecovery,
}

/// Append a new entry to the audit log.
///
/// Reads the last line of the existing log (if any) to compute the
/// chain hash, then appends a new JSONL line. If the last line is
/// corrupted (not valid JSON), a `ChainRecovery` entry is inserted
/// first to record the break point.
///
/// # Arguments
///
/// * `state_dir` — Path to `~/.localgpt/` (contains the audit log).
/// * `action` — What security event occurred.
/// * `content_sha256` — SHA-256 of the policy content (empty string if N/A).
/// * `source` — Who triggered the action.
pub fn append_audit_entry(
    state_dir: &Path,
    action: AuditAction,
    content_sha256: &str,
    source: &str,
) -> Result<()> {
    append_audit_entry_with_detail(state_dir, action, content_sha256, source, None)
}

/// Append a new entry to the audit log with an optional detail message.
pub fn append_audit_entry_with_detail(
    state_dir: &Path,
    action: AuditAction,
    content_sha256: &str,
    source: &str,
    detail: Option<&str>,
) -> Result<()> {
    let path = audit_file_path(state_dir);

    // Read the last line to compute the chain hash, with corruption recovery
    let prev_hash = if path.exists() {
        let content = fs::read_to_string(&path).context("Failed to read audit log")?;
        match content.lines().last() {
            Some(last_line) if !last_line.is_empty() => {
                // Attempt to parse as JSON to detect corruption
                if serde_json::from_str::<AuditEntry>(last_line).is_ok() {
                    sha256_hex(last_line.as_bytes())
                } else {
                    // Corrupted last line — write a ChainRecovery entry first
                    let raw_hash = sha256_hex(last_line.as_bytes());
                    let recovery = AuditEntry {
                        ts: chrono::Utc::now().to_rfc3339(),
                        action: AuditAction::ChainRecovery,
                        content_sha256: String::new(),
                        prev_entry_sha256: raw_hash,
                        source: "audit_system".to_string(),
                        detail: Some(format!(
                            "Previous entry corrupted ({} bytes), new chain segment",
                            last_line.len()
                        )),
                    };
                    let recovery_json = serde_json::to_string(&recovery)
                        .context("Failed to serialize recovery entry")?;
                    append_line(&path, &recovery_json)?;
                    sha256_hex(recovery_json.as_bytes())
                }
            }
            _ => GENESIS_HASH.to_string(),
        }
    } else {
        GENESIS_HASH.to_string()
    };

    let entry = AuditEntry {
        ts: chrono::Utc::now().to_rfc3339(),
        action,
        content_sha256: content_sha256.to_string(),
        prev_entry_sha256: prev_hash,
        source: source.to_string(),
        detail: detail.map(|d| d.to_string()),
    };

    let json = serde_json::to_string(&entry).context("Failed to serialize audit entry")?;
    append_line(&path, &json)?;

    Ok(())
}

/// Append a single line to a file.
fn append_line(path: &Path, line: &str) -> Result<()> {
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .context("Failed to open audit log")?;
    writeln!(file, "{}", line).context("Failed to write audit entry")?;
    Ok(())
}

/// Read and parse all entries from the audit log.
///
/// Corrupted lines are skipped (not fatal). Returns an empty vector
/// if the log file does not exist.
pub fn read_audit_log(state_dir: &Path) -> Result<Vec<AuditEntry>> {
    let path = audit_file_path(state_dir);

    if !path.exists() {
        return Ok(Vec::new());
    }

    let content = fs::read_to_string(&path).context("Failed to read audit log")?;
    let mut entries = Vec::new();

    for line in content.lines() {
        if line.is_empty() {
            continue;
        }
        // Skip corrupted lines rather than failing (RFC §5.6)
        if let Ok(entry) = serde_json::from_str::<AuditEntry>(line) {
            entries.push(entry);
        }
    }

    Ok(entries)
}

/// Verify the integrity of the audit log hash chain.
///
/// Returns a list of indices where the chain is broken (i.e., the
/// `prev_entry_sha256` does not match the SHA-256 of the previous line).
/// Corrupted (non-JSON) lines are reported as broken and skipped.
///
/// An empty return value means the chain is intact.
pub fn verify_audit_chain(state_dir: &Path) -> Result<Vec<usize>> {
    let path = audit_file_path(state_dir);

    if !path.exists() {
        return Ok(Vec::new());
    }

    let content = fs::read_to_string(&path).context("Failed to read audit log")?;
    let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();

    if lines.is_empty() {
        return Ok(Vec::new());
    }

    let mut broken = Vec::new();

    // Parse entries, tracking which lines are valid
    let mut parsed: Vec<Option<AuditEntry>> = Vec::new();
    for line in &lines {
        parsed.push(serde_json::from_str(line).ok());
    }

    // Check first entry
    if let Some(ref first) = parsed[0] {
        if first.prev_entry_sha256 != GENESIS_HASH {
            broken.push(0);
        }
    } else {
        broken.push(0); // Corrupted first line
    }

    // Check chain links
    for i in 1..lines.len() {
        if parsed[i].is_none() {
            broken.push(i); // Corrupted line
            continue;
        }
        let expected_hash = sha256_hex(lines[i - 1].as_bytes());
        if parsed[i].as_ref().unwrap().prev_entry_sha256 != expected_hash {
            broken.push(i);
        }
    }

    Ok(broken)
}

/// Get the full path to the audit log file.
pub fn audit_file_path(state_dir: &Path) -> PathBuf {
    state_dir.join(AUDIT_FILENAME)
}

/// Compute hex-encoded SHA-256.
fn sha256_hex(data: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher
        .finalize()
        .iter()
        .map(|b| format!("{:02x}", b))
        .collect()
}

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

    #[test]
    fn audit_chain_integrity() {
        let tmp = tempfile::tempdir().unwrap();

        // Write 5 entries
        for i in 0..5 {
            append_audit_entry(
                tmp.path(),
                AuditAction::Verified,
                &format!("sha256_{}", i),
                "test",
            )
            .unwrap();
        }

        let entries = read_audit_log(tmp.path()).unwrap();
        assert_eq!(entries.len(), 5);

        // Chain should be intact
        let broken = verify_audit_chain(tmp.path()).unwrap();
        assert!(broken.is_empty(), "Chain should be intact: {:?}", broken);
    }

    #[test]
    fn first_entry_uses_genesis_hash() {
        let tmp = tempfile::tempdir().unwrap();
        append_audit_entry(tmp.path(), AuditAction::Created, "abc123", "cli").unwrap();

        let entries = read_audit_log(tmp.path()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].prev_entry_sha256, GENESIS_HASH);
        assert_eq!(entries[0].action, AuditAction::Created);
    }

    #[test]
    fn broken_chain_detected() {
        let tmp = tempfile::tempdir().unwrap();

        // Write 3 valid entries
        for i in 0..3 {
            append_audit_entry(
                tmp.path(),
                AuditAction::Verified,
                &format!("sha256_{}", i),
                "test",
            )
            .unwrap();
        }

        // Tamper with the middle line
        let path = audit_file_path(tmp.path());
        let content = fs::read_to_string(&path).unwrap();
        let mut lines: Vec<&str> = content.lines().collect();

        // Replace middle line with different content (breaks chain for entry 2)
        let tampered = lines[1].replace("sha256_1", "tampered_hash");
        lines[1] = &tampered;

        fs::write(&path, lines.join("\n") + "\n").unwrap();

        let broken = verify_audit_chain(tmp.path()).unwrap();
        assert!(!broken.is_empty(), "Should detect broken chain");
        assert!(broken.contains(&2), "Entry 2 should have broken link");
    }

    #[test]
    fn empty_log_no_errors() {
        let tmp = tempfile::tempdir().unwrap();

        let entries = read_audit_log(tmp.path()).unwrap();
        assert!(entries.is_empty());

        let broken = verify_audit_chain(tmp.path()).unwrap();
        assert!(broken.is_empty());
    }

    #[test]
    fn audit_actions_serialize_snake_case() {
        let entry = AuditEntry {
            ts: "2026-02-09T14:00:00Z".to_string(),
            action: AuditAction::TamperDetected,
            content_sha256: "abc".to_string(),
            prev_entry_sha256: GENESIS_HASH.to_string(),
            source: "cli".to_string(),
            detail: None,
        };

        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"tamper_detected\""));
        // detail should be omitted when None
        assert!(!json.contains("\"detail\""));
    }

    #[test]
    fn detail_field_serialized_when_present() {
        let entry = AuditEntry {
            ts: "2026-02-09T14:00:00Z".to_string(),
            action: AuditAction::WriteBlocked,
            content_sha256: String::new(),
            prev_entry_sha256: GENESIS_HASH.to_string(),
            source: "tool:write_file".to_string(),
            detail: Some("Agent attempted write to LocalGPT.md".to_string()),
        };

        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"write_blocked\""));
        assert!(json.contains("Agent attempted write to LocalGPT.md"));

        // Roundtrip
        let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(
            parsed.detail.unwrap(),
            "Agent attempted write to LocalGPT.md"
        );
    }

    #[test]
    fn chain_recovery_on_corrupted_line() {
        let tmp = tempfile::tempdir().unwrap();

        // Write 2 valid entries
        append_audit_entry(tmp.path(), AuditAction::Signed, "abc", "cli").unwrap();
        append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "session_start").unwrap();

        // Corrupt the last line
        let path = audit_file_path(tmp.path());
        let mut content = fs::read_to_string(&path).unwrap();
        content.push_str("this is not json\n");
        fs::write(&path, &content).unwrap();

        // Next append should trigger ChainRecovery
        append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "session_start").unwrap();

        let entries = read_audit_log(tmp.path()).unwrap();
        // Should have: Signed, Verified, ChainRecovery, Verified (corrupted line skipped)
        assert_eq!(entries.len(), 4);
        assert_eq!(entries[2].action, AuditAction::ChainRecovery);
        assert!(entries[2].detail.as_ref().unwrap().contains("corrupted"));
        assert_eq!(entries[2].source, "audit_system");
    }

    #[test]
    fn corrupted_lines_skipped_in_read() {
        let tmp = tempfile::tempdir().unwrap();
        let path = audit_file_path(tmp.path());

        // Write a valid entry, then garbage, then another valid entry
        append_audit_entry(tmp.path(), AuditAction::Signed, "abc", "cli").unwrap();
        // Insert garbage directly
        let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
        writeln!(file, "not valid json garbage").unwrap();
        drop(file);
        // The next append will trigger chain recovery
        append_audit_entry(tmp.path(), AuditAction::Verified, "abc", "test").unwrap();

        let entries = read_audit_log(tmp.path()).unwrap();
        // Signed + ChainRecovery + Verified = 3 (garbage line skipped)
        assert_eq!(entries.len(), 3);
    }
}