use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::changeid::ChangeId;
use crate::objects::GitId;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Actor {
Human { name: String },
Agent { name: String, session: String },
Daemon,
}
impl Actor {
pub fn label(&self) -> String {
match self {
Actor::Human { name } => format!("human:{name}"),
Actor::Agent { name, session } => format!("agent:{name}/{session}"),
Actor::Daemon => "daemon".into(),
}
}
pub fn session(&self) -> Option<&str> {
match self {
Actor::Agent { session, .. } => Some(session),
_ => None,
}
}
}
impl fmt::Display for Actor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.label())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum OpKind {
#[serde(rename = "repo.init")]
RepoInit,
#[serde(rename = "human.enroll")]
HumanEnroll,
#[serde(rename = "human.rotate")]
HumanRotate,
#[serde(rename = "human.revoke")]
HumanRevoke,
#[serde(rename = "human.recovery_key")]
HumanRecoveryKey,
#[serde(rename = "human.recover")]
HumanRecover,
#[serde(rename = "daemon.rotate")]
DaemonRotate,
#[serde(rename = "daemon.recover")]
DaemonRecover,
#[serde(rename = "policy.update")]
PolicyUpdate,
#[serde(rename = "session.start")]
SessionStart,
#[serde(rename = "session.bind")]
SessionBind,
#[serde(rename = "session.isolate")]
SessionIsolate,
#[serde(rename = "session.end")]
SessionEnd,
#[serde(rename = "cap.attenuate")]
CapAttenuate,
#[serde(rename = "cap.revoke")]
CapRevoke,
#[serde(rename = "lease.acquire")]
LeaseAcquire,
#[serde(rename = "lease.release")]
LeaseRelease,
#[serde(rename = "lease.renew")]
LeaseRenew,
#[serde(rename = "lease.interest")]
LeaseInterest,
#[serde(rename = "change.propose")]
ChangePropose,
#[serde(rename = "change.withdraw")]
ChangeWithdraw,
#[serde(rename = "change.integrate")]
ChangeIntegrate,
#[serde(rename = "change.integrate_batch")]
ChangeIntegrateBatch,
#[serde(rename = "approve")]
Approve,
#[serde(rename = "reject")]
Reject,
#[serde(rename = "observe")]
Observe,
#[serde(rename = "taint")]
Taint,
#[serde(rename = "import.external")]
ImportExternal,
#[serde(rename = "import.unmediated")]
ImportUnmediated,
#[serde(rename = "bridge.push")]
BridgePush,
#[serde(rename = "freeze")]
Freeze,
#[serde(rename = "unfreeze")]
Unfreeze,
#[serde(rename = "negotiate")]
Negotiate,
#[serde(rename = "redact")]
Redact,
}
impl OpKind {
pub fn as_str(self) -> &'static str {
match self {
OpKind::RepoInit => "repo.init",
OpKind::HumanEnroll => "human.enroll",
OpKind::HumanRotate => "human.rotate",
OpKind::HumanRevoke => "human.revoke",
OpKind::HumanRecoveryKey => "human.recovery_key",
OpKind::HumanRecover => "human.recover",
OpKind::DaemonRotate => "daemon.rotate",
OpKind::DaemonRecover => "daemon.recover",
OpKind::PolicyUpdate => "policy.update",
OpKind::SessionStart => "session.start",
OpKind::SessionBind => "session.bind",
OpKind::SessionIsolate => "session.isolate",
OpKind::SessionEnd => "session.end",
OpKind::CapAttenuate => "cap.attenuate",
OpKind::CapRevoke => "cap.revoke",
OpKind::LeaseAcquire => "lease.acquire",
OpKind::LeaseRelease => "lease.release",
OpKind::LeaseRenew => "lease.renew",
OpKind::LeaseInterest => "lease.interest",
OpKind::ChangePropose => "change.propose",
OpKind::ChangeWithdraw => "change.withdraw",
OpKind::ChangeIntegrate => "change.integrate",
OpKind::ChangeIntegrateBatch => "change.integrate_batch",
OpKind::Approve => "approve",
OpKind::Reject => "reject",
OpKind::Observe => "observe",
OpKind::Taint => "taint",
OpKind::ImportExternal => "import.external",
OpKind::ImportUnmediated => "import.unmediated",
OpKind::BridgePush => "bridge.push",
OpKind::Freeze => "freeze",
OpKind::Unfreeze => "unfreeze",
OpKind::Negotiate => "negotiate",
OpKind::Redact => "redact",
}
}
pub fn agent_grantable(self) -> bool {
matches!(
self,
OpKind::ChangePropose
| OpKind::ChangeWithdraw
| OpKind::LeaseAcquire
| OpKind::LeaseRelease
| OpKind::LeaseRenew
| OpKind::LeaseInterest
| OpKind::Negotiate
| OpKind::CapAttenuate
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scope {
pub paths: Vec<String>,
pub symbols: Option<Vec<String>>,
pub ops: Vec<OpKind>,
pub max_files: Option<u32>,
pub max_lines: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scratch: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservationLevel {
Unobserved,
Hooks,
HooksSandbox,
Isolated,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BindChecks {
pub harness: String,
pub harness_session: String,
pub managed_hooks: bool,
pub locked_sandbox: bool,
pub sudo_without_password: bool,
pub config_digest: String,
pub hook_binary_protected: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LeaseKind {
Edit,
Insert,
Rename,
Shared,
}
impl LeaseKind {
pub const ALL: [LeaseKind; 4] = [Self::Edit, Self::Insert, Self::Rename, Self::Shared];
pub fn as_str(self) -> &'static str {
match self {
Self::Edit => "edit",
Self::Insert => "insert",
Self::Rename => "rename",
Self::Shared => "shared",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == name)
}
}
impl LeaseItem {
pub fn new(symbol: impl Into<String>, kind: LeaseKind) -> Self {
Self {
symbol: symbol.into(),
kind,
names: Vec::new(),
}
}
pub fn is_shared(&self) -> bool {
self.kind == LeaseKind::Shared
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct LeaseItem {
pub symbol: String,
pub kind: LeaseKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestDelta {
pub added: u32,
pub modified: u32,
pub deleted: u32,
pub skipped: u32,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claim {
pub intent: String,
pub edits: Vec<String>,
pub adds: Vec<String>,
pub deletes: Vec<String>,
pub deleted_files: Vec<String>,
pub signature_changes: Vec<String>,
pub tests: TestDelta,
pub revert_of: Option<ChangeId>,
}
pub const DEF: &str = "<def>";
pub fn names_are_headings(path: &str) -> bool {
let ext = path.rsplit_once('.').map(|(_, ext)| ext);
matches!(
ext,
Some("md" | "markdown" | "toml" | "json" | "yml" | "yaml")
)
}
pub const ENGINE_DEFS: u32 = 3;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Facts {
pub edits: Vec<String>,
pub adds: Vec<String>,
pub deletes: Vec<String>,
pub added_files: Vec<String>,
pub deleted_files: Vec<String>,
pub modified_files: Vec<String>,
pub signature_changes: Vec<String>,
pub import_only_files: Vec<String>,
pub tests: TestDelta,
pub lines_added: u32,
pub lines_removed: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub renames: Vec<[String; 2]>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unparsed_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unread_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub affected: Vec<String>,
#[serde(default, skip_serializing_if = "is_zero")]
pub affected_more: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
impl Facts {
pub fn reaches_others(&self) -> bool {
!self.signature_changes.is_empty() || !self.affected.is_empty() || self.affected_more > 0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckKind {
Syntax,
Scope,
ClaimDiff,
Merge,
Build,
Tests,
TestIntegrity,
Extended,
Named,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckResult {
Pass,
Fail,
Error,
Skipped,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub check: CheckKind,
pub result: CheckResult,
pub subject_tree: GitId,
pub details: BTreeMap<String, String>,
pub env: String,
pub log: Option<GitId>,
pub producer: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Pre,
Post,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ObsKind {
Read {
path: String,
},
Write {
path: String,
},
Shell {
program: String,
digest: String,
},
Fetch {
host: String,
},
Mcp {
tool: String,
},
Other {
tool: String,
},
Heartbeat,
Unproposed {
paths: Vec<String>,
total: u64,
},
Unhooked {
tool: String,
},
}
pub const MAX_UNPROPOSED_PATHS: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Observation {
pub phase: Phase,
pub kind: ObsKind,
pub external: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NegotiationMsg {
Claim {
symbols: Vec<String>,
reason: String,
},
ProposeSplit {
mine: Vec<String>,
yours: Vec<String>,
},
Yield {
set: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<String>,
},
Counter {
note: String,
},
Accept,
Decline {
note: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HumanKey {
pub alg: String,
pub public: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Continuation {
pub session: String,
pub widen: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum Op {
#[serde(rename = "repo.init")]
RepoInit {
repo: String,
daemon_key: String,
human: String,
human_key: HumanKey,
policy: GitId,
trunk: GitId,
},
#[serde(rename = "human.enroll")]
HumanEnroll { human: String, key: HumanKey },
#[serde(rename = "human.rotate")]
HumanRotate { human: String, key: HumanKey },
#[serde(rename = "human.revoke")]
HumanRevoke { human: String, reason: String },
#[serde(rename = "human.recovery_key")]
HumanRecoveryKey { human: String, key: HumanKey },
#[serde(rename = "human.recover")]
HumanRecover { human: String, key: HumanKey },
#[serde(rename = "daemon.rotate")]
DaemonRotate { key: String },
#[serde(rename = "daemon.recover")]
DaemonRecover { key: String },
#[serde(rename = "policy.update")]
PolicyUpdate { policy: GitId },
#[serde(rename = "session.start")]
SessionStart {
session: String,
agent: String,
model: Option<String>,
task: String,
scope: Scope,
expires_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
continues: Option<Continuation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
harness: Option<String>,
},
#[serde(rename = "session.bind")]
SessionBind {
session: String,
checks: BindChecks,
level: ObservationLevel,
},
#[serde(rename = "session.isolate")]
SessionIsolate {
session: String,
sandbox: String,
profile: String,
},
#[serde(rename = "session.end")]
SessionEnd { session: String, reason: String },
#[serde(rename = "cap.attenuate")]
CapAttenuate {
parent: String,
child: String,
agent: String,
scope: Scope,
expires_ms: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
leases: Vec<String>,
},
#[serde(rename = "cap.revoke")]
CapRevoke { session: String, reason: String },
#[serde(rename = "lease.acquire")]
LeaseAcquire {
set: String,
items: Vec<LeaseItem>,
intent: String,
ttl_ms: i64,
},
#[serde(rename = "lease.release")]
LeaseRelease { set: String },
#[serde(rename = "lease.renew")]
LeaseRenew { set: String, ttl_ms: i64 },
#[serde(rename = "lease.interest")]
LeaseInterest {
items: Vec<LeaseItem>,
intent: String,
},
#[serde(rename = "change.propose")]
ChangePropose {
change: ChangeId,
commit: GitId,
base: GitId,
claim: Claim,
},
#[serde(rename = "change.withdraw")]
ChangeWithdraw { change: ChangeId },
#[serde(rename = "change.integrate")]
ChangeIntegrate {
change: ChangeId,
trunk_before: GitId,
merged: Option<GitId>,
conflicts: Vec<String>,
},
#[serde(rename = "change.integrate_batch")]
ChangeIntegrateBatch {
trunk_before: GitId,
merged: Vec<(ChangeId, GitId)>,
},
#[serde(rename = "approve")]
Approve {
target_seq: u64,
note: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
checks: Vec<String>,
},
#[serde(rename = "reject")]
Reject { target_seq: u64, reason: String },
#[serde(rename = "observe")]
Observe {
session: String,
events: Vec<Observation>,
},
#[serde(rename = "taint")]
Taint { session: String, reason: String },
#[serde(rename = "import.external")]
ImportExternal {
remote: String,
commit: GitId,
trunk_before: GitId,
},
#[serde(rename = "import.unmediated")]
ImportUnmediated {
refname: String,
found: Option<GitId>,
expected: Option<GitId>,
},
#[serde(rename = "bridge.push")]
BridgePush {
remote: String,
oplog_seq: u64,
oplog_hash: String,
},
#[serde(rename = "freeze")]
Freeze { reason: String },
#[serde(rename = "unfreeze")]
Unfreeze { reason: String },
#[serde(rename = "negotiate")]
Negotiate {
thread: String,
to_session: String,
message: NegotiationMsg,
},
#[serde(rename = "redact")]
Redact { blob: GitId, reason: String },
}
impl Op {
pub fn kind(&self) -> OpKind {
match self {
Op::RepoInit { .. } => OpKind::RepoInit,
Op::HumanEnroll { .. } => OpKind::HumanEnroll,
Op::HumanRotate { .. } => OpKind::HumanRotate,
Op::HumanRevoke { .. } => OpKind::HumanRevoke,
Op::HumanRecoveryKey { .. } => OpKind::HumanRecoveryKey,
Op::HumanRecover { .. } => OpKind::HumanRecover,
Op::DaemonRotate { .. } => OpKind::DaemonRotate,
Op::DaemonRecover { .. } => OpKind::DaemonRecover,
Op::PolicyUpdate { .. } => OpKind::PolicyUpdate,
Op::SessionStart { .. } => OpKind::SessionStart,
Op::SessionBind { .. } => OpKind::SessionBind,
Op::SessionIsolate { .. } => OpKind::SessionIsolate,
Op::SessionEnd { .. } => OpKind::SessionEnd,
Op::CapAttenuate { .. } => OpKind::CapAttenuate,
Op::CapRevoke { .. } => OpKind::CapRevoke,
Op::LeaseAcquire { .. } => OpKind::LeaseAcquire,
Op::LeaseRelease { .. } => OpKind::LeaseRelease,
Op::LeaseRenew { .. } => OpKind::LeaseRenew,
Op::LeaseInterest { .. } => OpKind::LeaseInterest,
Op::ChangePropose { .. } => OpKind::ChangePropose,
Op::ChangeWithdraw { .. } => OpKind::ChangeWithdraw,
Op::ChangeIntegrate { .. } => OpKind::ChangeIntegrate,
Op::ChangeIntegrateBatch { .. } => OpKind::ChangeIntegrateBatch,
Op::Approve { .. } => OpKind::Approve,
Op::Reject { .. } => OpKind::Reject,
Op::Observe { .. } => OpKind::Observe,
Op::Taint { .. } => OpKind::Taint,
Op::ImportExternal { .. } => OpKind::ImportExternal,
Op::ImportUnmediated { .. } => OpKind::ImportUnmediated,
Op::BridgePush { .. } => OpKind::BridgePush,
Op::Freeze { .. } => OpKind::Freeze,
Op::Unfreeze { .. } => OpKind::Unfreeze,
Op::Negotiate { .. } => OpKind::Negotiate,
Op::Redact { .. } => OpKind::Redact,
}
}
pub fn daemon_key_switch(&self) -> Option<&str> {
match self {
Op::DaemonRotate { key } | Op::DaemonRecover { key } => Some(key),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Allow,
Deny,
Escalate,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub outcome: Outcome,
pub reasons: Vec<String>,
}
impl Decision {
pub fn allow() -> Self {
Self {
outcome: Outcome::Allow,
reasons: vec![],
}
}
pub fn deny(reason: impl Into<String>) -> Self {
Self {
outcome: Outcome::Deny,
reasons: vec![reason.into()],
}
}
pub fn escalate(reason: impl Into<String>) -> Self {
Self {
outcome: Outcome::Escalate,
reasons: vec![reason.into()],
}
}
pub fn is_allow(&self) -> bool {
self.outcome == Outcome::Allow
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HumanSignature {
pub human: String,
pub nonce: String,
pub issued_ms: i64,
pub sig: String,
}
#[derive(Serialize)]
pub struct HumanIntent<'a> {
pub purpose: &'static str,
pub repo: &'a str,
pub op: &'a Op,
pub nonce: &'a str,
pub issued_ms: i64,
}
impl<'a> HumanIntent<'a> {
pub fn new(repo: &'a str, op: &'a Op, nonce: &'a str, issued_ms: i64) -> Self {
Self {
purpose: "lonca-human-intent-v1",
repo,
op,
nonce,
issued_ms,
}
}
}
#[derive(Serialize)]
pub struct LoginIntent<'a> {
pub purpose: &'static str,
pub repo: Option<&'a str>,
pub human: &'a str,
pub ttl_ms: i64,
pub nonce: &'a str,
pub issued_ms: i64,
}
impl<'a> LoginIntent<'a> {
pub fn new(
repo: Option<&'a str>,
human: &'a str,
ttl_ms: i64,
nonce: &'a str,
issued_ms: i64,
) -> Self {
Self {
purpose: "lonca-human-login-v1",
repo,
human,
ttl_ms,
nonce,
issued_ms,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lease_kinds_are_named_as_they_are_written() {
for kind in LeaseKind::ALL {
let written = serde_json::to_value(kind).unwrap();
assert_eq!(written, kind.as_str());
assert_eq!(LeaseKind::from_name(kind.as_str()), Some(kind));
}
assert_eq!(LeaseKind::from_name("Shared"), None);
assert_eq!(LeaseKind::from_name(""), None);
}
#[test]
fn a_change_reaches_others_by_any_one_of_three() {
assert!(!Facts::default().reaches_others());
let signature = Facts {
signature_changes: vec!["a.rs#f".into()],
..Facts::default()
};
let mentioned = Facts {
affected: vec!["a.rs#g".into()],
..Facts::default()
};
let cut_off = Facts {
affected_more: 1,
..Facts::default()
};
assert!(signature.reaches_others());
assert!(mentioned.reaches_others());
assert!(cut_off.reaches_others());
}
#[test]
fn what_was_cut_from_affected_is_written_out() {
let none = serde_json::to_string(&Facts::default()).unwrap();
assert!(!none.contains("affected"), "{none}");
let cut_off = Facts {
affected_more: 3,
..Facts::default()
};
let text = serde_json::to_string(&cut_off).unwrap();
assert!(text.contains("\"affected_more\":3"), "{text}");
}
#[test]
fn unknown_op_kind_is_rejected() {
assert!(serde_json::from_str::<OpKind>("\"change.propose\"").is_ok());
assert!(serde_json::from_str::<OpKind>("\"admin.everything\"").is_err());
}
#[test]
fn op_kind_names_match_serde() {
for k in [
OpKind::RepoInit,
OpKind::ChangePropose,
OpKind::LeaseAcquire,
OpKind::Redact,
OpKind::SessionBind,
OpKind::SessionIsolate,
OpKind::HumanRotate,
OpKind::HumanRevoke,
OpKind::HumanRecoveryKey,
OpKind::HumanRecover,
OpKind::DaemonRotate,
OpKind::DaemonRecover,
] {
assert_eq!(
serde_json::to_string(&k).unwrap(),
format!("\"{}\"", k.as_str())
);
}
}
#[test]
fn reserved_ops_are_not_grantable() {
for k in [
OpKind::PolicyUpdate,
OpKind::Approve,
OpKind::CapRevoke,
OpKind::Redact,
OpKind::HumanEnroll,
OpKind::HumanRotate,
OpKind::HumanRevoke,
OpKind::HumanRecoveryKey,
OpKind::HumanRecover,
OpKind::DaemonRotate,
OpKind::DaemonRecover,
OpKind::Freeze,
OpKind::ImportExternal,
] {
assert!(!k.agent_grantable(), "{k:?}");
}
}
}