- 1
use std::path::{Path, PathBuf}; - 2
- 3
use chrono::{DateTime, Utc}; - 4
use serde::{Deserialize, Serialize}; - 5
- 6
fn security_events_path(home: &Path) -> PathBuf { - 7
home.join("security-events.jsonl") - 8
} - 9
- 10
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 11
pub struct SecurityEvent { - 12
pub ts: DateTime<Utc>, - 13
pub kind: EventKind, - 14
/// Short human-readable label (e.g. "rate_limit", "auth_failure"). - 15
pub label: String, - 16
/// Structured detail — freeform JSON string. - 17
pub detail: String, - 18
/// Source IP when available. - 19
#[serde(skip_serializing_if = "Option::is_none")] - 20
pub ip: Option<String>, - 21
} - 22
- 23
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 24
#[serde(rename_all = "snake_case")] - 25
pub enum EventKind { - 26
AuthFailure, - 27
RateLimit, - 28
ChatAllowlist, - 29
PermissionDenial, - 30
ConfigChange, - 31
ProviderKeyChange, - 32
FullAccessGrant, - 33
FullAccessRevoke, - 34
/// docs/design/34-channel-onboarding.md: an unknown inbound chat key - 35
/// was newly recorded as pending operator review. - 36
ChatPending, - 37
/// An operator approved a pending (or unknown) allowlist entry. - 38
ChatApproved, - 39
/// An operator denied a pending (or unknown) allowlist entry. - 40
ChatDenied, - 41
/// An operator revoked a previously allowed entry. - 42
ChatRevoked, - 43
/// docs/design/22-gateway.md: an inbound turn that was admitted but failed - 44
/// to execute (e.g. broker protocol failure, provider unavailable, lost - 45
/// session lock). Recorded so a silently-failed unattended turn is - 46
/// auditable instead of indistinguishable from a successful completion. - 47
ExecutionError, - 48
/// docs/design/34-channel-onboarding.md: a per-channel permission-mode - 49
/// override asked for more than the target workspace's own configured - 50
/// mode allows, and was capped down to that workspace's mode. A - 51
/// distinct kind rather than a `ConfigChange` so a silently-reduced - 52
/// grant is greppable in the audit log — it means an operator believes - 53
/// a channel has access it does not actually have. - 54
PermissionCapped, - 55
/// A capability this workspace configures could not be used on a turn, - 56
/// because the composed policy (mode + rules + channel overlay + - 57
/// approval mode + this surface's approver) refuses every call to it. - 58
/// - 59
/// Its own kind because it is the audit trail for a *silent* outcome: - 60
/// nothing else recorded that an operator's configured integration was - 61
/// unusable, so the only evidence was a denied tool call buried in a - 62
/// session transcript, and from outside it looked like the model simply - 63
/// never tried. - 64
CapabilityUnreachable, - 65
} - 66
- 67
/// Append a security event to `<home>/security-events.jsonl`. - 68
/// Best-effort: never panics, returns the event on success. - 69
pub fn record( - 70
home: &Path, - 71
kind: EventKind, - 72
label: &str, - 73
detail: &str, - 74
ip: Option<&str>, - 75
) -> SecurityEvent { - 76
let event = SecurityEvent { - 77
ts: Utc::now(), - 78
kind, - 79
label: label.to_string(), - 80
detail: detail.to_string(), - 81
ip: ip.map(str::to_string), - 82
}; - 83
let _ = append_event(home, &event); - 84
event - 85
} - 86
- 87
fn append_event(home: &Path, event: &SecurityEvent) -> Result<(), std::io::Error> { - 88
let path = security_events_path(home); - 89
if let Some(parent) = path.parent() { - 90
std::fs::create_dir_all(parent)?; - 91
} - 92
let mut line = serde_json::to_vec(event).map_err(std::io::Error::other)?; - 93
line.push(b'\n'); - 94
std::fs::OpenOptions::new() - 95
.create(true) - 96
.append(true) - 97
.open(path) - 98
.and_then(|mut f| std::io::Write::write_all(&mut f, &line)) - 99
} - 100
- 101
/// Read all security events (newest first), capped at `limit`. - 102
pub fn list(home: &Path, limit: usize) -> Vec<SecurityEvent> { - 103
let path = security_events_path(home); - 104
let Ok(raw) = std::fs::read_to_string(&path) else { - 105
return Vec::new(); - 106
}; - 107
let mut events: Vec<SecurityEvent> = raw - 108
.lines() - 109
.filter(|l| !l.trim().is_empty()) - 110
.filter_map(|l| serde_json::from_str(l).ok()) - 111
.collect(); - 112
events.reverse(); - 113
events.truncate(limit); - 114
events - 115
} - 116
- 117
#[cfg(test)] - 118
#[allow(clippy::unwrap_used, clippy::expect_used)] - 119
mod tests { - 120
use super::*; - 121
- 122
#[test] - 123
fn record_and_list_roundtrip() { - 124
let dir = tempfile::tempdir().unwrap(); - 125
let home = dir.path(); - 126
let ev = record( - 127
home, - 128
EventKind::AuthFailure, - 129
"bad_token", - 130
"wrong bearer supplied", - 131
Some("127.0.0.1"), - 132
); - 133
assert_eq!(ev.kind, EventKind::AuthFailure); - 134
let events = list(home, 10); - 135
assert_eq!(events.len(), 1); - 136
assert_eq!(events[0].label, "bad_token"); - 137
assert_eq!(events[0].ip.as_deref(), Some("127.0.0.1")); - 138
} - 139
- 140
#[test] - 141
fn list_returns_newest_first() { - 142
let dir = tempfile::tempdir().unwrap(); - 143
let home = dir.path(); - 144
record(home, EventKind::RateLimit, "first", "", None); - 145
std::thread::sleep(std::time::Duration::from_millis(10)); - 146
record(home, EventKind::RateLimit, "second", "", None); - 147
let events = list(home, 10); - 148
assert_eq!(events.len(), 2); - 149
assert_eq!(events[0].label, "second"); - 150
assert_eq!(events[1].label, "first"); - 151
} - 152
- 153
#[test] - 154
fn list_respects_limit() { - 155
let dir = tempfile::tempdir().unwrap(); - 156
let home = dir.path(); - 157
for i in 0..5 { - 158
record( - 159
home, - 160
EventKind::ConfigChange, - 161
&format!("change_{i}"), - 162
"", - 163
None, - 164
); - 165
} - 166
assert_eq!(list(home, 3).len(), 3); - 167
} - 168
- 169
#[test] - 170
fn missing_file_returns_empty() { - 171
let dir = tempfile::tempdir().unwrap(); - 172
let events = list(dir.path(), 10); - 173
assert!(events.is_empty()); - 174
} - 175
} - 176
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.