use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::hooks::{HookFile, TrustState};
pub const MAX_FILES: usize = 2_000;
pub const MAX_BYTES: u64 = 20 * 1024 * 1024;
pub const MAX_FINDINGS: usize = 200;
pub const MAX_SARIF: usize = 1024 * 1024;
const MAX_MESSAGE: usize = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionSource {
AgentPlugins,
SpecKit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AddedFrom {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
pub added: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ContributionKind {
Gate,
Observer,
View,
Skill,
McpServer,
Command,
HookOffer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Contribution {
pub kind: ContributionKind,
pub name: String,
pub line: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reaches: Vec<String>,
}
impl Contribution {
#[must_use]
pub const fn kind_words(&self) -> &'static str {
match self.kind {
ContributionKind::Gate => "gate",
ContributionKind::Observer => "observer",
ContributionKind::View => "view",
ContributionKind::Skill => "skill",
ContributionKind::McpServer => "MCP server",
ContributionKind::Command => "command",
ContributionKind::HookOffer => "hook",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ExtensionChanges {
pub files: Vec<String>,
pub widened: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gone: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Extension {
pub name: String,
pub source: ExtensionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub folder: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<AddedFrom>,
pub digest: String,
pub files: Vec<HookFile>,
pub contributions: Vec<Contribution>,
pub trust: TrustState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changes: Option<ExtensionChanges>,
pub trust_flag: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub problems: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ExtensionsReport {
pub extensions: Vec<Extension>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trusted: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed: Option<String>,
}
#[must_use]
pub fn valid_name(name: &str) -> bool {
let b = name.as_bytes();
(1..=64).contains(&b.len())
&& b.iter()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'-' | b'.'))
&& b[0].is_ascii_alphanumeric()
&& b[b.len() - 1].is_ascii_alphanumeric()
&& !name.contains("--")
&& !name.contains("..")
}
#[must_use]
pub fn server_name(extension: &str, server: &str) -> String {
let mut out = String::new();
for c in format!("{extension}_{server}").to_lowercase().chars() {
let c = if c.is_ascii_alphanumeric() { c } else { '_' };
if !(c == '_' && (out.is_empty() || out.ends_with('_'))) {
out.push(c);
}
}
out.trim_end_matches('_').to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewWidget {
Findings,
Table,
Metrics,
Markdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewPlace {
#[default]
Feature,
Project,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewSource {
Run(Vec<String>),
File(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ViewDef {
pub name: String,
pub title: String,
pub source: ViewSource,
pub widget: ViewWidget,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub columns: Vec<String>,
pub place: ViewPlace,
pub timeout: u64,
pub exit: Vec<i32>,
}
const VIEW_KEYS: &[&str] = &[
"title", "run", "file", "widget", "path", "columns", "place", "timeout", "exit",
];
pub fn parse_view(name: &str, table: &Value) -> Result<ViewDef, String> {
let at = |m: &str| format!("[views.{name}]: {m}");
if !crate::hooks::valid_name(name) {
return Err(at(
"a view's name is lowercase letters, digits, `-` and `_`",
));
}
let Some(t) = table.as_object() else {
return Err(at("must be a table"));
};
if let Some(k) = t.keys().find(|k| !VIEW_KEYS.contains(&k.as_str())) {
return Err(at(&format!(
"unknown key `{k}` (known: {})",
VIEW_KEYS.join(", ")
)));
}
let string = |k: &str| -> Result<Option<String>, String> {
match t.get(k) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(at(&format!("`{k}` is a string"))),
}
};
let strings = |k: &str| -> Result<Option<Vec<String>>, String> {
match t.get(k) {
None => Ok(None),
Some(Value::Array(a)) => a
.iter()
.map(|v| v.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()
.map(Some)
.ok_or_else(|| at(&format!("`{k}` is a list of strings"))),
Some(_) => Err(at(&format!("`{k}` is a list of strings"))),
}
};
let title = string("title")?.unwrap_or_else(|| name.to_owned());
if title.chars().count() > 60 {
return Err(at("`title` is at most 60 characters"));
}
let source = match (strings("run")?, string("file")?) {
(Some(_), Some(_)) => return Err(at("give `run` or `file`, not both")),
(None, None) => return Err(at("add `run = [\"…\"]` or `file = \"…\"`")),
(Some(a), None) if a.first().is_none_or(|p| p.trim().is_empty()) => {
return Err(at("`run` names the program first"));
}
(Some(a), None) => ViewSource::Run(a),
(None, Some(f)) => {
if f.is_empty() || f.starts_with('/') || f.split(['/', '\\']).any(|s| s == "..") {
return Err(at(&format!(
"`file`: `{f}` is not a path inside the project"
)));
}
ViewSource::File(f)
}
};
let widget = match string("widget")?.as_deref() {
Some("findings") => ViewWidget::Findings,
Some("table") => ViewWidget::Table,
Some("metrics") => ViewWidget::Metrics,
Some("markdown") => ViewWidget::Markdown,
Some(w) => {
return Err(at(&format!(
"`widget = \"{w}\"` is not supported by this version (findings, table, metrics, markdown)"
)));
}
None => return Err(at("add `widget` (findings, table, metrics, markdown)")),
};
let place = match string("place")?.as_deref() {
None | Some("feature") => ViewPlace::Feature,
Some("project") => ViewPlace::Project,
Some(p) => return Err(at(&format!("`place = \"{p}\"`: feature or project"))),
};
let timeout = match t.get("timeout") {
None => 60,
Some(v) => match v.as_u64() {
Some(s) if (1..=crate::hooks::MAX_TIMEOUT).contains(&s) => s,
_ => {
return Err(at(&format!(
"`timeout` is seconds, 1 to {}",
crate::hooks::MAX_TIMEOUT
)));
}
},
};
let exit = match t.get("exit") {
None => vec![0],
Some(Value::Array(a)) if !a.is_empty() => a
.iter()
.map(|v| v.as_i64().and_then(|n| i32::try_from(n).ok()))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| at("`exit` is a list of exit codes"))?,
Some(_) => return Err(at("`exit` is a list of exit codes")),
};
if !matches!(source, ViewSource::Run(_)) && t.contains_key("exit") {
return Err(at("`exit` is for a `run` source"));
}
Ok(ViewDef {
name: name.to_owned(),
title,
source,
widget,
path: string("path")?,
columns: strings("columns")?.unwrap_or_default(),
place,
timeout,
exit,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "widget")]
pub enum ViewData {
Findings {
findings: Vec<SarifFinding>,
},
Table {
columns: Vec<String>,
rows: Vec<Vec<String>>,
},
Metrics {
metrics: Vec<(String, String)>,
},
Markdown {
html: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ViewResult {
pub inputs: String,
pub ran_at: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<ViewData>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ViewShown {
pub extension: String,
pub view: ViewDef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<ViewResult>,
pub waits_for_trust: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ViewsReport {
pub views: Vec<ViewShown>,
}
#[must_use]
pub fn host(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
host.rsplit_once('@').map_or(host, |(_, h)| h).to_owned()
}
#[must_use]
pub fn shown(argv: &[String]) -> String {
argv.iter()
.map(|a| {
if a.is_empty() || a.contains([' ', '"', '\'']) {
format!("{a:?}")
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn hook_contribution(name: &str, def: &crate::hooks::HookDef) -> Contribution {
use crate::hooks::{Role, Runner};
match (&def.role, &def.runner) {
(Role::Gate { at, network }, runner) => {
let runs = match runner {
Runner::Argv(a) => shown(a),
Runner::Url { url, .. } => url.clone(),
};
let net = if *network { "network on" } else { "no network" };
Contribution {
kind: ContributionKind::Gate,
name: name.to_owned(),
line: format!("gate {}: runs {runs} — {net}", at.words()),
reaches: if *network {
vec!["network".to_owned()]
} else {
Vec::new()
},
}
}
(Role::Observer { on }, runner) => {
let mut reaches: Vec<String> = on.iter().map(|e| format!("event:{e}")).collect();
let does = match runner {
Runner::Argv(a) => {
reaches.push("network".to_owned());
format!("runs {} (network on)", shown(a))
}
Runner::Url { url, .. } => {
let h = host(url);
reaches.push(format!("host:{h}"));
format!("posts to {h}")
}
};
Contribution {
kind: ContributionKind::Observer,
name: name.to_owned(),
line: format!("observer, {}: {does}", def.listens()),
reaches,
}
}
}
}
#[must_use]
pub fn view_contribution(name: &str, v: &ViewDef) -> Contribution {
let place = match v.place {
ViewPlace::Feature => "on each feature",
ViewPlace::Project => "on the project",
};
let from = match &v.source {
ViewSource::Run(a) => format!("runs {} read-only, no network", shown(a)),
ViewSource::File(f) => format!("reads {f}"),
};
Contribution {
kind: ContributionKind::View,
name: name.to_owned(),
line: format!("view “{}” {place}: {from}", v.title),
reaches: Vec::new(),
}
}
#[must_use]
pub fn widened(before: &[Contribution], now: &[Contribution]) -> (Vec<String>, Vec<String>) {
let mut widened = Vec::new();
for c in now {
match before.iter().find(|b| b.name == c.name && b.kind == c.kind) {
None => widened.push(format!("new — {}", c.line)),
Some(b) => {
let more: Vec<&String> = c
.reaches
.iter()
.filter(|r| !b.reaches.contains(r))
.collect();
if !more.is_empty() {
let more: Vec<&str> = more.iter().map(|s| s.as_str()).collect();
widened.push(format!("reaches {} — {}", more.join(", "), c.line));
}
}
}
}
let gone = before
.iter()
.filter(|b| !now.iter().any(|c| c.name == b.name && c.kind == b.kind))
.map(|b| b.line.clone())
.collect();
(widened, gone)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct SarifFinding {
pub rule: String,
pub level: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u64>,
}
pub fn sarif(
text: &str,
relative: impl Fn(&str) -> Option<String>,
) -> Result<Vec<SarifFinding>, String> {
if text.len() > MAX_SARIF {
return Err(format!(
"the findings are {} KB; at most {} KB are read",
text.len() / 1024,
MAX_SARIF / 1024
));
}
let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
let Some(runs) = v.get("runs").and_then(Value::as_array) else {
return Err("not SARIF: no `runs`".to_owned());
};
let mut out = Vec::new();
for r in runs.iter().flat_map(|r| {
r.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}) {
if out.len() == MAX_FINDINGS {
break;
}
let loc = &r["locations"][0]["physicalLocation"];
let file = match loc["artifactLocation"]["uri"].as_str() {
None => None,
Some(u) => {
let u = u.trim_start_matches("file://").trim_start_matches("./");
Some(
relative(u)
.ok_or_else(|| format!("a finding names `{u}`, outside the project"))?,
)
}
};
let message: String = r["message"]["text"]
.as_str()
.unwrap_or_default()
.chars()
.take(MAX_MESSAGE)
.collect();
out.push(SarifFinding {
rule: r["ruleId"].as_str().unwrap_or("finding").to_owned(),
level: match r["level"].as_str() {
Some(l @ ("error" | "warning" | "note" | "none")) => l.to_owned(),
_ => "warning".to_owned(),
},
message,
file,
line: loc["region"]["startLine"].as_u64(),
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn names_follow_agent_plugins() {
for ok in ["threatspec", "a", "my.tool-2"] {
assert!(valid_name(ok), "{ok}");
}
for bad in ["", "-a", "a-", "A", "a--b", "a..b", "a_b", &"x".repeat(65)] {
assert!(!valid_name(bad), "{bad}");
}
assert_eq!(server_name("my.tool", "echo_1"), "my_tool_echo_1");
assert_eq!(server_name("plain-plugin", "notes"), "plain_plugin_notes");
}
#[test]
fn a_view_has_one_source_and_a_widget() {
let v = parse_view(
"findings",
&json!({"run": ["./check.sh", "--sarif"], "widget": "findings", "exit": [0, 1, 2]}),
)
.unwrap();
assert_eq!(v.exit, vec![0, 1, 2]);
assert_eq!(v.timeout, 60);
assert_eq!(v.place, ViewPlace::Feature);
assert!(parse_view("x", &json!({"widget": "table"})).is_err());
assert!(parse_view("x", &json!({"run": ["a"], "file": "b", "widget": "table"})).is_err());
let e = parse_view("x", &json!({"file": "a.md", "widget": "chart"})).unwrap_err();
assert!(e.contains("not supported by this version"), "{e}");
assert!(parse_view("x", &json!({"file": "../a.md", "widget": "markdown"})).is_err());
assert!(
parse_view(
"x",
&json!({"file": "a.md", "widget": "markdown", "colour": 1})
)
.is_err()
);
assert!(
parse_view(
"x",
&json!({"file": "a.md", "widget": "markdown", "exit": [1]})
)
.is_err()
);
}
#[test]
fn a_new_host_or_the_network_widens() {
let gate = |net: bool| {
let def = crate::hooks::parse(
"check",
&json!({"at": "tick", "argv": ["./check.sh"], "network": net}),
)
.unwrap();
hook_contribution("x.check", &def)
};
let off = gate(false);
assert!(
off.line
.contains("gate before tick: runs ./check.sh — no network"),
"{}",
off.line
);
let (w, gone) = widened(std::slice::from_ref(&off), &[gate(true)]);
assert_eq!(w.len(), 1);
assert!(w[0].starts_with("reaches network"), "{w:?}");
assert!(gone.is_empty());
let obs = crate::hooks::parse(
"notify",
&json!({"on": ["task.stopped"], "url": "https://hooks.example.com/x"}),
)
.unwrap();
let (w, _) = widened(&[off], &[hook_contribution("x.notify", &obs)]);
assert!(w[0].starts_with("new — observer"), "{w:?}");
assert!(w[0].contains("posts to hooks.example.com"), "{w:?}");
}
#[test]
fn sarif_is_read_within_bounds() {
let log = json!({"version": "2.1.0", "runs": [{"results": [
{"ruleId": "TS-1", "level": "error", "message": {"text": "unmitigated"},
"locations": [{"physicalLocation": {"artifactLocation": {"uri": "specs/a/spec.md"},
"region": {"startLine": 3}}}]}]}]});
let rel = |p: &str| (!p.starts_with('/')).then(|| p.to_owned());
let f = sarif(&log.to_string(), rel).unwrap();
assert_eq!(f[0].rule, "TS-1");
assert_eq!(f[0].line, Some(3));
let outside = log.to_string().replace("specs/a/spec.md", "/etc/passwd");
assert!(sarif(&outside, rel).is_err());
assert!(sarif("{}", rel).is_err());
assert!(sarif(&"x".repeat(MAX_SARIF + 1), rel).is_err());
}
}