use std::collections::BTreeMap;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::model::{Fingerprint, Symbol};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Intent,
Decision,
Attempt,
Constraint,
Gotcha,
Question,
Relation,
}
impl Kind {
pub fn parse(s: &str) -> Option<Kind> {
Some(match s {
"intent" => Kind::Intent,
"decision" => Kind::Decision,
"attempt" => Kind::Attempt,
"constraint" => Kind::Constraint,
"gotcha" => Kind::Gotcha,
"question" => Kind::Question,
_ => return None,
})
}
pub fn as_str(self) -> &'static str {
match self {
Kind::Intent => "intent",
Kind::Decision => "decision",
Kind::Attempt => "attempt",
Kind::Constraint => "constraint",
Kind::Gotcha => "gotcha",
Kind::Question => "question",
Kind::Relation => "relation",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelationKind {
Supersedes,
Retracts,
Confirms,
Disputes,
}
impl RelationKind {
pub fn as_str(self) -> &'static str {
match self {
RelationKind::Supersedes => "supersede",
RelationKind::Retracts => "retract",
RelationKind::Confirms => "confirm",
RelationKind::Disputes => "dispute",
}
}
pub fn parse(s: &str) -> Option<RelationKind> {
Some(match s {
"confirm" | "confirms" => RelationKind::Confirms,
"retract" | "retracts" => RelationKind::Retracts,
"supersede" | "supersedes" => RelationKind::Supersedes,
"dispute" | "disputes" => RelationKind::Disputes,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Relation {
pub kind: RelationKind,
pub target: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorKind {
Human,
Agent,
Harvested,
Distilled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub author: AuthorKind,
pub actor: String,
pub source: String,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub teams: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Identity {
pub actor: String,
pub name: Option<String>,
}
impl Provenance {
pub fn human(source: &str, who: &Identity) -> Provenance {
Provenance {
author: AuthorKind::Human,
actor: who.actor.clone(),
source: source.into(),
agent: None,
session: None,
name: who.name.clone(),
teams: Vec::new(),
}
}
pub fn agent(source: &str, agent: Option<String>, who: &Identity) -> Provenance {
Provenance {
author: AuthorKind::Agent,
agent,
..Provenance::human(source, who)
}
}
pub fn who(&self) -> String {
let agent = self.agent.as_deref().filter(|a| *a != "rmcp");
match (self.author, self.name.as_deref(), agent) {
(AuthorKind::Agent, Some(n), Some(a)) => format!("{a} for {n}"),
(AuthorKind::Agent, Some(n), None) => format!("an agent for {n}"),
(AuthorKind::Agent, None, Some(a)) => a.to_string(),
(AuthorKind::Agent, None, None) => "an agent".into(),
(AuthorKind::Human, Some(n), _) => n.to_string(),
(AuthorKind::Human, None, _) => "a person".into(),
(AuthorKind::Harvested, ..) => "git history".into(),
(AuthorKind::Distilled, ..) => "the distiller".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Anchor {
pub symbol: String,
pub repo: String,
pub path: String,
pub name: String,
pub line: u32,
#[serde(default)]
pub fingerprint: Option<Fingerprint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
}
impl Anchor {
pub fn of(s: &Symbol) -> Anchor {
Anchor {
symbol: s.id.render(),
repo: s.repo.0.clone(),
path: s.file_path.clone(),
name: s.name.clone(),
line: s.range.start.line,
fingerprint: s.fingerprint,
commit: None,
}
}
pub fn at(mut self, commits: &std::collections::HashMap<String, String>) -> Anchor {
self.commit = commits.get(&self.repo).cloned();
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snippet {
pub path: String,
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Record {
pub id: String,
pub schema: u32,
pub kind: Kind,
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub fields: BTreeMap<String, serde_json::Value>,
pub anchors: Vec<Anchor>,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub snippets: Vec<Snippet>,
#[serde(default)]
pub relation: Option<Relation>,
pub provenance: Provenance,
pub created: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
impl Record {
pub fn new(kind: Kind, title: &str, anchors: Vec<Anchor>, provenance: Provenance) -> Record {
Record {
id: uuid::Uuid::now_v7().to_string(),
schema: SCHEMA,
kind,
title: title.trim().to_string(),
body: String::new(),
trigger: None,
fields: BTreeMap::new(),
anchors,
evidence: Vec::new(),
snippets: Vec::new(),
relation: None,
provenance,
created: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
signature: None,
}
}
pub fn review(
target: &Record,
action: &str,
reason: &str,
provenance: Provenance,
) -> Result<Record> {
let (kind, retire) = review_action(action).with_context(|| {
format!("unknown action `{action}`: confirm, archive, invalidate, supersede, dispute or retract")
})?;
let mut r = Record::relation(kind, target, reason, provenance);
if let Some(why) = retire {
r.fields
.insert("retire".into(), serde_json::Value::String(why.into()));
}
Ok(r)
}
pub fn vouching(mut self, target: &Record, now: impl Fn(&str) -> Option<Anchor>) -> Record {
if self.relation.as_ref().map(|rel| rel.kind) == Some(RelationKind::Confirms) {
self.anchors = target
.anchors
.iter()
.filter_map(|a| now(&a.symbol))
.collect();
}
self
}
pub fn relation(
kind: RelationKind,
target: &Record,
reason: &str,
provenance: Provenance,
) -> Record {
let title = if reason.trim().is_empty() {
format!("{kind:?}: {}", target.title)
} else {
reason.trim().to_string()
};
let mut r = Record::new(Kind::Relation, &title, Vec::new(), provenance);
r.relation = Some(Relation {
kind,
target: target.id.clone(),
});
r
}
fn text(&self) -> String {
let fields: Vec<String> = self.fields.values().map(|v| v.to_string()).collect();
let code: Vec<&str> = self.snippets.iter().map(|s| s.code.as_str()).collect();
format!(
"{} {} {} {} {} {}",
self.title,
self.body,
self.trigger.as_deref().unwrap_or(""),
self.evidence.join(" "),
fields.join(" "),
code.join(" ")
)
}
pub fn unsafe_content(&self) -> bool {
let t = self.text();
suspicious(&t).is_some()
|| (self.provenance.author != AuthorKind::Human && injection(&t).is_some())
}
pub fn validate(&self) -> Result<()> {
if self.title.is_empty() || self.title.chars().count() > 160 {
bail!("title must be 1–160 characters");
}
if self.body.chars().count() > 4000 {
bail!("body is limited to 4000 characters");
}
if self.kind != Kind::Relation && self.anchors.is_empty() {
bail!("a record needs at least one anchor (a symbol it is about)");
}
if self.fields.contains_key("check") && self.kind != Kind::Constraint {
bail!("only constraints carry a check");
}
if self.snippets.len() > MAX_SNIPPETS {
bail!("at most {MAX_SNIPPETS} snippets");
}
if self.snippets.iter().any(|s| {
s.path.trim().is_empty()
|| s.code.trim().is_empty()
|| s.code.lines().count() > MAX_SNIPPET_LINES
|| s.code.chars().count() > 4000
}) {
bail!("a snippet needs a path and 1–{MAX_SNIPPET_LINES} lines of code (4000 characters at most)");
}
if self.kind == Kind::Attempt && self.evidence.is_empty() {
bail!("an attempt needs evidence (a revert commit, failing test, error, MR, …)");
}
let text = self.text();
if let Some(why) = suspicious(&text) {
bail!("refusing to store record: {why}");
}
if self.provenance.author != AuthorKind::Human {
if let Some(why) = injection(&text) {
bail!("refusing to store record: {why}");
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Active,
Moved,
Stale,
Orphaned,
Superseded,
Retracted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
pub enum Trust {
T0,
T1,
T2,
T3,
T4,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub record: Record,
pub status: Status,
pub trust: Trust,
pub current: Vec<String>,
pub related: Vec<(RelationKind, String)>,
pub verified: Option<bool>,
pub by: String,
pub history: Vec<Event>,
pub retired: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outside: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vouched: Vec<Anchor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alike: Option<Alike>,
}
impl Entry {
pub fn anchors(&self) -> &[Anchor] {
if self.vouched.is_empty() {
&self.record.anchors
} else {
&self.vouched
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alike {
pub id: String,
pub title: String,
pub score: f32,
}
pub const SCHEMA: u32 = 1;
pub fn review_action(action: &str) -> Option<(RelationKind, Option<&'static str>)> {
match action {
"archive" => Some((RelationKind::Retracts, Some("outdated"))),
"invalidate" => Some((RelationKind::Retracts, Some("wrong"))),
other => RelationKind::parse(other).map(|k| (k, None)),
}
}
pub const MAX_SNIPPETS: usize = 3;
pub const MAX_SNIPPET_LINES: usize = 60;
pub fn injection(text: &str) -> Option<&'static str> {
let t = text.to_lowercase();
const STEERING: &[&str] = &[
"ignore previous",
"ignore all previous",
"ignore the above",
"disregard previous",
"disregard the above",
"system prompt",
"you are now",
"new instructions",
"<system",
"</system",
"<instructions",
"assistant:",
"do not tell the user",
"without telling the user",
];
const PAYLOADS: &[&str] = &[
"| sh",
"| bash",
"|sh",
"|bash",
"rm -rf",
"base64 -d",
"base64 --decode",
"<script",
"javascript:",
"chmod +x",
"eval(",
"/etc/passwd",
".ssh/",
];
if STEERING.iter().any(|p| t.contains(p)) {
return Some("reads like instructions to an agent");
}
if PAYLOADS.iter().any(|p| t.contains(p)) {
return Some("contains a shell or script payload");
}
None
}
pub fn suspicious(text: &str) -> Option<&'static str> {
if text
.chars()
.any(|c| matches!(c as u32, 0x202A..=0x202E | 0x2066..=0x2069 | 0xE0000..=0xE007F))
{
return Some("contains invisible or bidirectional control characters");
}
let lower = text.to_ascii_lowercase();
let markers = [
"-----begin",
"aws_secret_access_key",
"xoxb-",
"ghp_",
"glpat-",
"sk-ant-",
"password=",
];
if markers.iter().any(|m| lower.contains(m))
|| text.split_whitespace().any(|w| {
w.starts_with("AKIA") && w.len() == 20 && w.chars().all(|c| c.is_ascii_alphanumeric())
})
{
return Some("looks like it contains a secret");
}
None
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signature {
pub key: String,
pub sig: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub action: String,
pub who: String,
pub at: String,
pub reason: String,
pub counted: bool,
}