1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::hooks::{HookFile, TrustState};
12
13pub const MAX_FILES: usize = 2_000;
15pub const MAX_BYTES: u64 = 20 * 1024 * 1024;
17pub const MAX_FINDINGS: usize = 200;
19pub const MAX_SARIF: usize = 1024 * 1024;
21const MAX_MESSAGE: usize = 500;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum ExtensionSource {
28 AgentPlugins,
30 SpecKit,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36pub struct AddedFrom {
37 pub url: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub commit: Option<String>,
42 pub added: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum ContributionKind {
50 Gate,
52 Observer,
54 View,
56 Skill,
58 McpServer,
60 Command,
62 HookOffer,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct Contribution {
69 pub kind: ContributionKind,
71 pub name: String,
73 pub line: String,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub reaches: Vec<String>,
79}
80
81impl Contribution {
82 #[must_use]
84 pub const fn kind_words(&self) -> &'static str {
85 match self.kind {
86 ContributionKind::Gate => "gate",
87 ContributionKind::Observer => "observer",
88 ContributionKind::View => "view",
89 ContributionKind::Skill => "skill",
90 ContributionKind::McpServer => "MCP server",
91 ContributionKind::Command => "command",
92 ContributionKind::HookOffer => "hook",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99pub struct ExtensionChanges {
100 pub files: Vec<String>,
102 pub widened: Vec<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub gone: Vec<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111pub struct Extension {
112 pub name: String,
114 pub source: ExtensionSource,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub version: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub description: Option<String>,
122 pub folder: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub origin: Option<AddedFrom>,
127 pub digest: String,
129 pub files: Vec<HookFile>,
131 pub contributions: Vec<Contribution>,
133 pub trust: TrustState,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub changes: Option<ExtensionChanges>,
138 pub trust_flag: String,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub problems: Vec<String>,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
147pub struct ExtensionsReport {
148 pub extensions: Vec<Extension>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub trusted: Vec<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub added: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub removed: Option<String>,
159}
160
161#[must_use]
164pub fn valid_name(name: &str) -> bool {
165 let b = name.as_bytes();
166 (1..=64).contains(&b.len())
167 && b.iter()
168 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'-' | b'.'))
169 && b[0].is_ascii_alphanumeric()
170 && b[b.len() - 1].is_ascii_alphanumeric()
171 && !name.contains("--")
172 && !name.contains("..")
173}
174
175#[must_use]
179pub fn server_name(extension: &str, server: &str) -> String {
180 let mut out = String::new();
181 for c in format!("{extension}_{server}").to_lowercase().chars() {
182 let c = if c.is_ascii_alphanumeric() { c } else { '_' };
183 if !(c == '_' && (out.is_empty() || out.ends_with('_'))) {
184 out.push(c);
185 }
186 }
187 out.trim_end_matches('_').to_owned()
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
194#[serde(rename_all = "snake_case")]
195pub enum ViewWidget {
196 Findings,
198 Table,
200 Metrics,
202 Markdown,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
208#[serde(rename_all = "snake_case")]
209pub enum ViewPlace {
210 #[default]
212 Feature,
213 Project,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
219#[serde(rename_all = "snake_case")]
220pub enum ViewSource {
221 Run(Vec<String>),
223 File(String),
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229pub struct ViewDef {
230 pub name: String,
232 pub title: String,
234 pub source: ViewSource,
236 pub widget: ViewWidget,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub path: Option<String>,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub columns: Vec<String>,
244 pub place: ViewPlace,
246 pub timeout: u64,
248 pub exit: Vec<i32>,
250}
251
252const VIEW_KEYS: &[&str] = &[
253 "title", "run", "file", "widget", "path", "columns", "place", "timeout", "exit",
254];
255
256pub fn parse_view(name: &str, table: &Value) -> Result<ViewDef, String> {
262 let at = |m: &str| format!("[views.{name}]: {m}");
263 if !crate::hooks::valid_name(name) {
264 return Err(at(
265 "a view's name is lowercase letters, digits, `-` and `_`",
266 ));
267 }
268 let Some(t) = table.as_object() else {
269 return Err(at("must be a table"));
270 };
271 if let Some(k) = t.keys().find(|k| !VIEW_KEYS.contains(&k.as_str())) {
272 return Err(at(&format!(
273 "unknown key `{k}` (known: {})",
274 VIEW_KEYS.join(", ")
275 )));
276 }
277 let string = |k: &str| -> Result<Option<String>, String> {
278 match t.get(k) {
279 None => Ok(None),
280 Some(Value::String(s)) => Ok(Some(s.clone())),
281 Some(_) => Err(at(&format!("`{k}` is a string"))),
282 }
283 };
284 let strings = |k: &str| -> Result<Option<Vec<String>>, String> {
285 match t.get(k) {
286 None => Ok(None),
287 Some(Value::Array(a)) => a
288 .iter()
289 .map(|v| v.as_str().map(str::to_owned))
290 .collect::<Option<Vec<_>>>()
291 .map(Some)
292 .ok_or_else(|| at(&format!("`{k}` is a list of strings"))),
293 Some(_) => Err(at(&format!("`{k}` is a list of strings"))),
294 }
295 };
296 let title = string("title")?.unwrap_or_else(|| name.to_owned());
297 if title.chars().count() > 60 {
298 return Err(at("`title` is at most 60 characters"));
299 }
300 let source = match (strings("run")?, string("file")?) {
301 (Some(_), Some(_)) => return Err(at("give `run` or `file`, not both")),
302 (None, None) => return Err(at("add `run = [\"…\"]` or `file = \"…\"`")),
303 (Some(a), None) if a.first().is_none_or(|p| p.trim().is_empty()) => {
304 return Err(at("`run` names the program first"));
305 }
306 (Some(a), None) => ViewSource::Run(a),
307 (None, Some(f)) => {
308 if f.is_empty() || f.starts_with('/') || f.split(['/', '\\']).any(|s| s == "..") {
309 return Err(at(&format!(
310 "`file`: `{f}` is not a path inside the project"
311 )));
312 }
313 ViewSource::File(f)
314 }
315 };
316 let widget = match string("widget")?.as_deref() {
317 Some("findings") => ViewWidget::Findings,
318 Some("table") => ViewWidget::Table,
319 Some("metrics") => ViewWidget::Metrics,
320 Some("markdown") => ViewWidget::Markdown,
321 Some(w) => {
322 return Err(at(&format!(
323 "`widget = \"{w}\"` is not supported by this version (findings, table, metrics, markdown)"
324 )));
325 }
326 None => return Err(at("add `widget` (findings, table, metrics, markdown)")),
327 };
328 let place = match string("place")?.as_deref() {
329 None | Some("feature") => ViewPlace::Feature,
330 Some("project") => ViewPlace::Project,
331 Some(p) => return Err(at(&format!("`place = \"{p}\"`: feature or project"))),
332 };
333 let timeout = match t.get("timeout") {
334 None => 60,
335 Some(v) => match v.as_u64() {
336 Some(s) if (1..=crate::hooks::MAX_TIMEOUT).contains(&s) => s,
337 _ => {
338 return Err(at(&format!(
339 "`timeout` is seconds, 1 to {}",
340 crate::hooks::MAX_TIMEOUT
341 )));
342 }
343 },
344 };
345 let exit = match t.get("exit") {
346 None => vec![0],
347 Some(Value::Array(a)) if !a.is_empty() => a
348 .iter()
349 .map(|v| v.as_i64().and_then(|n| i32::try_from(n).ok()))
350 .collect::<Option<Vec<_>>>()
351 .ok_or_else(|| at("`exit` is a list of exit codes"))?,
352 Some(_) => return Err(at("`exit` is a list of exit codes")),
353 };
354 if !matches!(source, ViewSource::Run(_)) && t.contains_key("exit") {
355 return Err(at("`exit` is for a `run` source"));
356 }
357 Ok(ViewDef {
358 name: name.to_owned(),
359 title,
360 source,
361 widget,
362 path: string("path")?,
363 columns: strings("columns")?.unwrap_or_default(),
364 place,
365 timeout,
366 exit,
367 })
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
372#[serde(rename_all = "snake_case", tag = "widget")]
373pub enum ViewData {
374 Findings {
376 findings: Vec<SarifFinding>,
378 },
379 Table {
381 columns: Vec<String>,
383 rows: Vec<Vec<String>>,
385 },
386 Metrics {
388 metrics: Vec<(String, String)>,
390 },
391 Markdown {
393 html: String,
395 },
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct ViewResult {
401 pub inputs: String,
403 pub ran_at: String,
405 pub duration_ms: u64,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub data: Option<ViewData>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub error: Option<String>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
417pub struct ViewShown {
418 pub extension: String,
420 pub view: ViewDef,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub result: Option<ViewResult>,
425 pub waits_for_trust: bool,
427}
428
429#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct ViewsReport {
432 pub views: Vec<ViewShown>,
434}
435
436#[must_use]
440pub fn host(url: &str) -> String {
441 let rest = url.split_once("://").map_or(url, |(_, r)| r);
442 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
443 host.rsplit_once('@').map_or(host, |(_, h)| h).to_owned()
444}
445
446#[must_use]
448pub fn shown(argv: &[String]) -> String {
449 argv.iter()
450 .map(|a| {
451 if a.is_empty() || a.contains([' ', '"', '\'']) {
452 format!("{a:?}")
453 } else {
454 a.clone()
455 }
456 })
457 .collect::<Vec<_>>()
458 .join(" ")
459}
460
461#[must_use]
463pub fn hook_contribution(name: &str, def: &crate::hooks::HookDef) -> Contribution {
464 use crate::hooks::{Role, Runner};
465 match (&def.role, &def.runner) {
466 (Role::Gate { at, network }, runner) => {
467 let runs = match runner {
468 Runner::Argv(a) => shown(a),
469 Runner::Url { url, .. } => url.clone(),
470 };
471 let net = if *network { "network on" } else { "no network" };
472 Contribution {
473 kind: ContributionKind::Gate,
474 name: name.to_owned(),
475 line: format!("gate {}: runs {runs} — {net}", at.words()),
476 reaches: if *network {
477 vec!["network".to_owned()]
478 } else {
479 Vec::new()
480 },
481 }
482 }
483 (Role::Observer { on }, runner) => {
484 let mut reaches: Vec<String> = on.iter().map(|e| format!("event:{e}")).collect();
485 let does = match runner {
486 Runner::Argv(a) => {
487 reaches.push("network".to_owned());
488 format!("runs {} (network on)", shown(a))
489 }
490 Runner::Url { url, .. } => {
491 let h = host(url);
492 reaches.push(format!("host:{h}"));
493 format!("posts to {h}")
494 }
495 };
496 Contribution {
497 kind: ContributionKind::Observer,
498 name: name.to_owned(),
499 line: format!("observer, {}: {does}", def.listens()),
500 reaches,
501 }
502 }
503 }
504}
505
506#[must_use]
508pub fn view_contribution(name: &str, v: &ViewDef) -> Contribution {
509 let place = match v.place {
510 ViewPlace::Feature => "on each feature",
511 ViewPlace::Project => "on the project",
512 };
513 let from = match &v.source {
514 ViewSource::Run(a) => format!("runs {} read-only, no network", shown(a)),
515 ViewSource::File(f) => format!("reads {f}"),
516 };
517 Contribution {
518 kind: ContributionKind::View,
519 name: name.to_owned(),
520 line: format!("view “{}” {place}: {from}", v.title),
521 reaches: Vec::new(),
522 }
523}
524
525#[must_use]
527pub fn widened(before: &[Contribution], now: &[Contribution]) -> (Vec<String>, Vec<String>) {
528 let mut widened = Vec::new();
529 for c in now {
530 match before.iter().find(|b| b.name == c.name && b.kind == c.kind) {
531 None => widened.push(format!("new — {}", c.line)),
532 Some(b) => {
533 let more: Vec<&String> = c
534 .reaches
535 .iter()
536 .filter(|r| !b.reaches.contains(r))
537 .collect();
538 if !more.is_empty() {
539 let more: Vec<&str> = more.iter().map(|s| s.as_str()).collect();
540 widened.push(format!("reaches {} — {}", more.join(", "), c.line));
541 }
542 }
543 }
544 }
545 let gone = before
546 .iter()
547 .filter(|b| !now.iter().any(|c| c.name == b.name && c.kind == b.kind))
548 .map(|b| b.line.clone())
549 .collect();
550 (widened, gone)
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
557pub struct SarifFinding {
558 pub rule: String,
560 pub level: String,
562 pub message: String,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub file: Option<String>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub line: Option<u64>,
570}
571
572pub fn sarif(
581 text: &str,
582 relative: impl Fn(&str) -> Option<String>,
583) -> Result<Vec<SarifFinding>, String> {
584 if text.len() > MAX_SARIF {
585 return Err(format!(
586 "the findings are {} KB; at most {} KB are read",
587 text.len() / 1024,
588 MAX_SARIF / 1024
589 ));
590 }
591 let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
592 let Some(runs) = v.get("runs").and_then(Value::as_array) else {
593 return Err("not SARIF: no `runs`".to_owned());
594 };
595 let mut out = Vec::new();
596 for r in runs.iter().flat_map(|r| {
597 r.get("results")
598 .and_then(Value::as_array)
599 .cloned()
600 .unwrap_or_default()
601 }) {
602 if out.len() == MAX_FINDINGS {
603 break;
604 }
605 let loc = &r["locations"][0]["physicalLocation"];
606 let file = match loc["artifactLocation"]["uri"].as_str() {
607 None => None,
608 Some(u) => {
609 let u = u.trim_start_matches("file://").trim_start_matches("./");
610 Some(
611 relative(u)
612 .ok_or_else(|| format!("a finding names `{u}`, outside the project"))?,
613 )
614 }
615 };
616 let message: String = r["message"]["text"]
617 .as_str()
618 .unwrap_or_default()
619 .chars()
620 .take(MAX_MESSAGE)
621 .collect();
622 out.push(SarifFinding {
623 rule: r["ruleId"].as_str().unwrap_or("finding").to_owned(),
624 level: match r["level"].as_str() {
625 Some(l @ ("error" | "warning" | "note" | "none")) => l.to_owned(),
626 _ => "warning".to_owned(),
627 },
628 message,
629 file,
630 line: loc["region"]["startLine"].as_u64(),
631 });
632 }
633 Ok(out)
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use serde_json::json;
640
641 #[test]
642 fn names_follow_agent_plugins() {
643 for ok in ["threatspec", "a", "my.tool-2"] {
644 assert!(valid_name(ok), "{ok}");
645 }
646 for bad in ["", "-a", "a-", "A", "a--b", "a..b", "a_b", &"x".repeat(65)] {
647 assert!(!valid_name(bad), "{bad}");
648 }
649 assert_eq!(server_name("my.tool", "echo_1"), "my_tool_echo_1");
650 assert_eq!(server_name("plain-plugin", "notes"), "plain_plugin_notes");
651 }
652
653 #[test]
654 fn a_view_has_one_source_and_a_widget() {
655 let v = parse_view(
656 "findings",
657 &json!({"run": ["./check.sh", "--sarif"], "widget": "findings", "exit": [0, 1, 2]}),
658 )
659 .unwrap();
660 assert_eq!(v.exit, vec![0, 1, 2]);
661 assert_eq!(v.timeout, 60);
662 assert_eq!(v.place, ViewPlace::Feature);
663 assert!(parse_view("x", &json!({"widget": "table"})).is_err());
664 assert!(parse_view("x", &json!({"run": ["a"], "file": "b", "widget": "table"})).is_err());
665 let e = parse_view("x", &json!({"file": "a.md", "widget": "chart"})).unwrap_err();
666 assert!(e.contains("not supported by this version"), "{e}");
667 assert!(parse_view("x", &json!({"file": "../a.md", "widget": "markdown"})).is_err());
668 assert!(
669 parse_view(
670 "x",
671 &json!({"file": "a.md", "widget": "markdown", "colour": 1})
672 )
673 .is_err()
674 );
675 assert!(
676 parse_view(
677 "x",
678 &json!({"file": "a.md", "widget": "markdown", "exit": [1]})
679 )
680 .is_err()
681 );
682 }
683
684 #[test]
685 fn a_new_host_or_the_network_widens() {
686 let gate = |net: bool| {
687 let def = crate::hooks::parse(
688 "check",
689 &json!({"at": "tick", "argv": ["./check.sh"], "network": net}),
690 )
691 .unwrap();
692 hook_contribution("x.check", &def)
693 };
694 let off = gate(false);
695 assert!(
696 off.line
697 .contains("gate before tick: runs ./check.sh — no network"),
698 "{}",
699 off.line
700 );
701 let (w, gone) = widened(std::slice::from_ref(&off), &[gate(true)]);
702 assert_eq!(w.len(), 1);
703 assert!(w[0].starts_with("reaches network"), "{w:?}");
704 assert!(gone.is_empty());
705 let obs = crate::hooks::parse(
706 "notify",
707 &json!({"on": ["task.stopped"], "url": "https://hooks.example.com/x"}),
708 )
709 .unwrap();
710 let (w, _) = widened(&[off], &[hook_contribution("x.notify", &obs)]);
711 assert!(w[0].starts_with("new — observer"), "{w:?}");
712 assert!(w[0].contains("posts to hooks.example.com"), "{w:?}");
713 }
714
715 #[test]
716 fn sarif_is_read_within_bounds() {
717 let log = json!({"version": "2.1.0", "runs": [{"results": [
718 {"ruleId": "TS-1", "level": "error", "message": {"text": "unmitigated"},
719 "locations": [{"physicalLocation": {"artifactLocation": {"uri": "specs/a/spec.md"},
720 "region": {"startLine": 3}}}]}]}]});
721 let rel = |p: &str| (!p.starts_with('/')).then(|| p.to_owned());
722 let f = sarif(&log.to_string(), rel).unwrap();
723 assert_eq!(f[0].rule, "TS-1");
724 assert_eq!(f[0].line, Some(3));
725 let outside = log.to_string().replace("specs/a/spec.md", "/etc/passwd");
726 assert!(sarif(&outside, rel).is_err());
727 assert!(sarif("{}", rel).is_err());
728 assert!(sarif(&"x".repeat(MAX_SARIF + 1), rel).is_err());
729 }
730}