1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! The tap manifest — what a plugin repository ADVERTISES (`kyyn-tap.ron`
//! at its root): which plugins it serves, through which binary, with which
//! declared capabilities. The engine reads this; plugins never do. Unknown
//! fields are refused — the `tap` version field is the evolution path, and
//! an engine that cannot parse a manifest must say so, not guess (ADR 0005).
use serde::{Deserialize, Serialize};
/// The tap manifest format version this engine speaks.
pub const TAP_FORMAT: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapManifest {
/// Manifest format version — an engine refuses versions it does not speak.
pub tap: u32,
/// The cargo bin target that serves every advertised plugin
/// (`<binary> --plugin <name>`, one RON request per invocation).
pub binary: String,
pub plugins: Vec<TapPluginDecl>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapPluginDecl {
/// The name sources reference (`tap:<tap>#<name>`).
pub name: String,
/// One line for the catalog and the consent card.
pub summary: String,
/// The link namespace its evidence carries (collision-checked at install).
pub namespace: String,
/// What accepting this plugin authorizes — shown on the consent card
/// (declared now, enforced in stages; ADR 0005).
#[serde(default)]
pub capabilities: TapCapabilities,
/// The instance-config fields this plugin understands — drives the web
/// install form (a labeled field per entry instead of a raw RON box).
/// Declarative on purpose: preview must never build or execute code,
/// so config metadata lives HERE, not behind a protocol verb. Empty =
/// the UI falls back to a raw RON input.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<TapConfigField>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapConfigField {
pub name: String,
/// One line of guidance, shown under the input.
pub doc: String,
/// How the web quotes the value into RON. Default `Str`.
#[serde(default)]
pub ty: TapConfigType,
#[serde(default)]
pub required: bool,
/// Placeholder / example value (unquoted).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
/// The plugin's default when omitted — display only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TapConfigType {
/// A string — the form value is RON-quoted.
#[default]
Str,
Int,
Bool,
/// Comma-separated in the form; each entry RON-quoted into a list.
StrList,
/// Raw RON, inserted verbatim (escape hatch for structured values).
Ron,
/// A host filesystem root this instance may READ (quoted like `Str`).
/// This is a CAPABILITY declaration: the runtime sandbox bind-mounts
/// exactly the configured `Path` values read-only — a plugin whose
/// manifest declares no `Path` fields sees no host filesystem beyond
/// its own checkout. The consent card lists these.
Path,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TapCapabilities {
/// Network hosts the plugin may reach. Empty = no network.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub network: Vec<String>,
/// The credential realm family it signs into (`ms-graph`). None = no auth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<String>,
}
fn bare_token(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
impl TapManifest {
pub fn parse(text: &str) -> Result<TapManifest, String> {
let m: TapManifest = ron::from_str(text).map_err(|e| format!("kyyn-tap.ron: {e}"))?;
if m.tap != TAP_FORMAT {
return Err(format!(
"tap manifest format v{} — this engine speaks v{TAP_FORMAT}",
m.tap
));
}
// Identifiers become paths and trust anchors: the binary is joined
// under target/release (a separator would escape it), plugin names
// ride in source references, namespaces gate links. Bare tokens
// only, refused at parse — never at some later join.
if !bare_token(&m.binary) {
return Err(format!(
"binary '{}' must be a bare cargo target name (no separators or dots)",
m.binary
));
}
let mut seen = std::collections::HashSet::new();
for p in &m.plugins {
if !bare_token(&p.name) {
return Err(format!("plugin name '{}' must be a bare token", p.name));
}
if !bare_token(&p.namespace) {
return Err(format!(
"plugin '{}': namespace '{}' must be a bare token",
p.name, p.namespace
));
}
if !seen.insert(p.name.clone()) {
return Err(format!("plugin '{}' is advertised twice", p.name));
}
}
Ok(m)
}
pub fn plugin(&self, name: &str) -> Option<&TapPluginDecl> {
self.plugins.iter().find(|p| p.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_parses_and_gates_version_and_unknown_fields() {
let m = TapManifest::parse(
r#"(tap: 1, binary: "kyyn-plugins", plugins: [
(name: "sweep", summary: "glob a tree", namespace: "file"),
(name: "graph-mail", summary: "mail", namespace: "graph",
capabilities: (network: ["graph.microsoft.com"], auth: Some("ms-graph"))),
])"#,
)
.expect("parses");
assert_eq!(m.binary, "kyyn-plugins");
assert!(m.plugin("sweep").is_some());
assert_eq!(
m.plugin("graph-mail").unwrap().capabilities.network,
vec!["graph.microsoft.com"]
);
assert!(m.plugin("nope").is_none());
let err = TapManifest::parse(r#"(tap: 2, binary: "x", plugins: [])"#).unwrap_err();
assert!(err.contains("speaks v1"), "{err}");
assert!(
TapManifest::parse(r#"(tap: 1, binary: "x", plugins: [], sneaky: 1)"#).is_err(),
"unknown fields refused"
);
// Identifiers become paths: traversal, absolute paths and dots in
// the binary name are refused at parse.
for evil in ["../../src/runner", "/usr/bin/env", "a/b", "a.sh", ""] {
let m = format!(r#"(tap: 1, binary: "{evil}", plugins: [])"#);
assert!(TapManifest::parse(&m).is_err(), "{evil}");
}
assert!(
TapManifest::parse(
r#"(tap: 1, binary: "x", plugins: [
(name: "a", summary: "s", namespace: "n"),
(name: "a", summary: "s", namespace: "n"),
])"#
)
.is_err(),
"duplicate plugin names refused"
);
}
}