1use crate::llm::actions::{
12 protocol_trait::{ActionResult, Protocol, Server},
13 ActionDefinition, Parameter, ParameterDefinition,
14};
15use crate::protocol::log_template::LogTemplate;
16use crate::protocol::EventType;
17use crate::state::app_state::AppState;
18use anyhow::{Context, Result};
19use serde_json::{json, Value};
20use std::sync::LazyLock;
21
22use super::api;
23
24const TOKEN_NOTE: &str = "token_present / token_matches_configured / token_configured describe \
25 the X-Vault-Token header without revealing it; refuse with send_vault_error status 403 \
26 errors [\"permission denied\"] when a configured token does not match. With no token \
27 configured any non-empty token counts as matching.";
28
29pub static VAULT_READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
32 EventType::new(
33 "vault_read",
34 format!(
35 "A Vault client (vault kv get, vault kv metadata get, an SDK) read the secret at \
36 `path` in the KV v2 mount `mount`. Answer with send_vault_secret: its data for \
37 what=\"data\" (version, when present, is the version asked for), or just version \
38 and created_time for what=\"metadata\". Use send_vault_error status 404 with no \
39 errors if no such secret exists. {TOKEN_NOTE}"
40 ),
41 json!({
42 "type": "send_vault_secret",
43 "data": {"username": "app", "password": "s3cr3t"},
44 "version": 3
45 }),
46 )
47 .with_actions(vec![send_vault_secret_action(), send_vault_error_action()])
48});
49
50pub static VAULT_WRITE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
52 EventType::new(
53 "vault_write",
54 format!(
55 "A Vault client (vault kv put) wrote `data` to `path` in the KV v2 mount `mount`. \
56 Remember it if later reads should return it, then answer send_vault_write_ok with \
57 the new version number (one more than the previous version of this path, 1 for a \
58 new path). `cas`, when present, is the version the client expects to replace. \
59 {TOKEN_NOTE}"
60 ),
61 json!({"type": "send_vault_write_ok", "version": 1}),
62 )
63 .with_actions(vec![
64 send_vault_write_ok_action(),
65 send_vault_error_action(),
66 ])
67});
68
69pub static VAULT_LIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
71 EventType::new(
72 "vault_list",
73 format!(
74 "A Vault client (vault kv list) listed the keys under `path` in the KV v2 mount \
75 `mount` (an empty path is the mount's root). Answer send_vault_list with the \
76 names directly under it, folders ending in '/'. Use send_vault_error status 404 \
77 with no errors if nothing is there. {TOKEN_NOTE}"
78 ),
79 json!({"type": "send_vault_list", "keys": ["db", "api-keys/"]}),
80 )
81 .with_actions(vec![send_vault_list_action(), send_vault_error_action()])
82});
83
84pub struct VaultProtocol;
86
87impl VaultProtocol {
88 pub fn new() -> Self {
89 Self
90 }
91}
92
93impl Default for VaultProtocol {
94 fn default() -> Self {
95 Self::new()
96 }
97}
98
99impl Protocol for VaultProtocol {
100 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
101 Vec::new()
102 }
103
104 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
105 vec![
106 send_vault_secret_action(),
107 send_vault_write_ok_action(),
108 send_vault_list_action(),
109 send_vault_error_action(),
110 ]
111 }
112
113 fn protocol_name(&self) -> &'static str {
114 "Vault"
115 }
116
117 fn get_event_types(&self) -> Vec<EventType> {
118 vec![
119 VAULT_READ_EVENT.clone(),
120 VAULT_WRITE_EVENT.clone(),
121 VAULT_LIST_EVENT.clone(),
122 ]
123 }
124
125 fn stack_name(&self) -> &'static str {
126 "ETH>IP>TCP>HTTP>VAULT"
127 }
128
129 fn keywords(&self) -> Vec<&'static str> {
130 vec![
131 "vault",
132 "hashicorp vault",
133 "openbao",
134 "secrets engine",
135 "kv v2",
136 ]
137 }
138
139 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
140 use crate::protocol::metadata::{
141 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
142 };
143
144 ProtocolMetadataV2::builder()
145 .state(DevelopmentState::Beta)
150 .well_known_port(8200)
151 .privilege_requirement(PrivilegeRequirement::None)
152 .implementation(
153 "hyper HTTP/1.1, plain HTTP. sys/seal-status, sys/health, sys/leader and the \
154 CLI's KV-version preflight (sys/internal/ui/mounts/<path>, answering KV v2 for \
155 every configured mount) are served deterministically; KV v2 data reads, \
156 metadata reads, writes and lists (LIST or GET ?list=true) raise vault_read / \
157 vault_write / vault_list and the model's answer is wrapped in Vault's \
158 envelope by NetGet.",
159 )
160 .llm_control(
161 "Every secret, key listing and version number. NetGet stores nothing: the model \
162 keeps what was written (memory or the SQLite facility) if later reads should \
163 return it. The token is shown to the model only as booleans.",
164 )
165 .e2e_testing(
166 "tests/server/vault/real_client_test.rs - the real vault CLI (VAULT_ADDR at \
167 NetGet, HOME a temp dir so no token helper is read) runs status, kv put, kv \
168 get (-field and -format=json), kv list and kv metadata get against a script \
169 handler and must print exactly its values. HARD FAILS when vault is absent. \
170 e2e_test.rs covers the mocked model, including a token mismatch refused 403.",
171 )
172 .notes(
173 "KV version 2 only, on the mounts named by kv_mounts (default: secret). Not \
174 implemented: KV v1, delete / undelete / destroy / patch / metadata writes \
175 (405), every other secrets engine and auth method, sys/mounts, token lookup, \
176 policies, leases, TLS. Authentication is the model's decision: with a token \
177 startup parameter the event says whether the presented token matches; with \
178 none, any token is accepted and the event says so (token_configured false).",
179 )
180 .max_inbound_bytes(super::MAX_REQUEST_BODY_BYTES)
181 .answers_on_failure()
184 .request_only("The Vault API is HTTP request/response; a server cannot send a peer anything unprompted")
185 .build()
186 }
187
188 fn description(&self) -> &'static str {
189 "HashiCorp Vault (KV v2) whose secrets the model keeps"
190 }
191
192 fn example_prompt(&self) -> &'static str {
193 "Be a Vault server on port 8200 whose secret mount holds database credentials at \
194 app/db and an API key at app/stripe"
195 }
196
197 fn group_name(&self) -> &'static str {
198 "AI & API"
199 }
200
201 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
202 vec![
203 ParameterDefinition {
204 name: "token".to_string(),
205 type_hint: "string".to_string(),
206 description: "The token clients must present in X-Vault-Token. Events report \
207 whether it matched (never the token). Without it, any non-empty \
208 token counts as matching."
209 .to_string(),
210 required: false,
211 example: json!("hvs.netget-dev-token"),
212 default: None,
213 },
214 ParameterDefinition {
215 name: "kv_mounts".to_string(),
216 type_hint: "array".to_string(),
217 description: "KV version 2 mount paths, e.g. [\"secret\", \"kv/prod\"] \
218 (default [\"secret\"]). Paths under any other mount are 404."
219 .to_string(),
220 required: false,
221 example: json!(["secret"]),
222 default: None,
223 },
224 ParameterDefinition {
225 name: "vault_version".to_string(),
226 type_hint: "string".to_string(),
227 description: format!(
228 "Version reported by vault status and sys/health (default {}).",
229 super::DEFAULT_VAULT_VERSION
230 ),
231 required: false,
232 example: json!("1.18.3"),
233 default: Some(serde_json::json!(super::DEFAULT_VAULT_VERSION)),
234 },
235 ]
236 }
237
238 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
239 use crate::llm::actions::StartupExamples;
240
241 StartupExamples::new(
242 json!({
243 "type": "open_server",
244 "port": 8200,
245 "base_stack": "vault",
246 "instruction": "Vault for a payments service. secret/app/db holds username \
247 payments and password Tr0ub4dor&3 (version 4); secret/app/stripe \
248 holds api_key sk_test_123. Keep anything written in memory and \
249 return it on later reads with the version incremented. Refuse \
250 with 403 permission denied when the token does not match.",
251 "startup_params": {"token": "hvs.netget-dev-token"}
252 }),
253 json!({
254 "type": "open_server",
255 "port": 8200,
256 "base_stack": "vault",
257 "event_handlers": [
258 {
259 "event_pattern": "vault_read",
260 "handler": {
261 "type": "script",
262 "language": "python",
263 "code": "import json, sys\nevent = json.load(sys.stdin)['event']\nsecrets = {'app/db': {'username': 'app', 'password': 's3cr3t'}}\nif not event.get('token_matches_configured'):\n act = {'type': 'send_vault_error', 'status': 403, 'errors': ['permission denied']}\nelif event['path'] in secrets:\n act = {'type': 'send_vault_secret', 'data': secrets[event['path']], 'version': 2}\nelse:\n act = {'type': 'send_vault_error', 'status': 404, 'errors': []}\nprint(json.dumps({'actions': [act]}))"
264 }
265 },
266 {
267 "event_pattern": "vault_list",
268 "handler": {
269 "type": "script",
270 "language": "python",
271 "code": "import json, sys\nevent = json.load(sys.stdin)['event']\nkeys = {'': ['app/'], 'app': ['db']}.get(event['path'].strip('/'))\nact = {'type': 'send_vault_list', 'keys': keys} if keys else {'type': 'send_vault_error', 'status': 404, 'errors': []}\nprint(json.dumps({'actions': [act]}))"
272 }
273 },
274 {
275 "event_pattern": "vault_write",
276 "handler": {
277 "type": "script",
278 "language": "python",
279 "code": "import json, sys\nevent = json.load(sys.stdin)['event']\nprint(json.dumps({'actions': [{'type': 'send_vault_write_ok', 'version': event.get('cas', 0) + 1}]}))"
280 }
281 }
282 ]
283 }),
284 json!({
285 "type": "open_server",
286 "port": 8200,
287 "base_stack": "vault",
288 "event_handlers": [{
289 "event_pattern": "vault_read",
290 "handler": {
291 "type": "static",
292 "actions": [{
293 "type": "send_vault_secret",
294 "data": {"username": "app", "password": "s3cr3t"},
295 "version": 1
296 }]
297 }
298 }]
299 }),
300 )
301 }
302}
303
304impl Server for VaultProtocol {
305 fn spawn(
306 &self,
307 ctx: crate::protocol::SpawnContext,
308 ) -> std::pin::Pin<
309 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
310 > {
311 Box::pin(async move {
312 let mut token = None;
313 let mut mounts = vec!["secret".to_string()];
314 let mut version = super::DEFAULT_VAULT_VERSION.to_string();
315 if let Some(params) = ctx.startup_params.as_ref() {
316 token = params
317 .get_optional_string("token")?
318 .filter(|t| !t.is_empty());
319 if let Some(list) = params.get_optional_array("kv_mounts")? {
320 let mut parsed = Vec::with_capacity(list.len());
321 for m in list {
322 let m = m
323 .as_str()
324 .map(|s| s.trim_matches('/').to_string())
325 .filter(|s| {
326 !s.is_empty()
327 && !s.starts_with("sys")
328 && s.split('/').all(|seg| {
329 !seg.is_empty()
330 && seg.chars().all(|c| {
331 c.is_ascii_alphanumeric()
332 || matches!(c, '-' | '_' | '.')
333 })
334 })
335 })
336 .with_context(|| {
337 format!(
338 "kv_mounts entry {m} must be a mount path like \"secret\" \
339 or \"kv/prod\" (and not under sys/)"
340 )
341 })?;
342 parsed.push(m);
343 }
344 if parsed.is_empty() {
345 anyhow::bail!("kv_mounts must name at least one mount");
346 }
347 mounts = parsed;
348 }
349 if let Some(v) = params.get_optional_string("vault_version")? {
350 version = v;
351 }
352 }
353 super::VaultServer::spawn_with_llm_actions(
354 ctx.legacy_listen_addr(),
355 ctx.llm_client,
356 ctx.state,
357 ctx.status_tx,
358 ctx.server_id,
359 super::VaultConfig {
360 identity: api::VaultIdentity {
361 version,
362 cluster_name: "vault-cluster-netget".to_string(),
363 },
364 kv_mounts: mounts,
365 token,
366 },
367 )
368 .await
369 })
370 }
371
372 fn execute_action(&self, action: Value) -> Result<ActionResult> {
373 let action_type = action
374 .get("type")
375 .and_then(|v| v.as_str())
376 .context("Missing 'type' field in action")?;
377 let refuse = |reason: String| anyhow::anyhow!("{action_type} refused: {reason}");
378 let mut data = action.clone();
379 if let Some(obj) = data.as_object_mut() {
380 obj.remove("type");
381 }
382 match action_type {
383 "send_vault_secret" => {
384 api::render_secret(&data).map_err(refuse)?;
385 }
386 "send_vault_write_ok" => {
387 api::render_write_ok(&data).map_err(refuse)?;
388 }
389 "send_vault_list" => {
390 api::render_list(&data).map_err(refuse)?;
391 }
392 "send_vault_error" => {
393 api::render_error(&data).map_err(refuse)?;
394 }
395 other => return Err(anyhow::anyhow!("Unknown Vault action: {other}")),
396 }
397 Ok(ActionResult::Custom {
398 name: action_type.to_string(),
399 data,
400 })
401 }
402}
403
404fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
405 Parameter {
406 name: name.to_string(),
407 type_hint: type_hint.to_string(),
408 description: description.to_string(),
409 required,
410 }
411}
412
413fn send_vault_secret_action() -> ActionDefinition {
414 ActionDefinition {
415 name: "send_vault_secret".to_string(),
416 description: "Answer a vault_read with the secret. For what=\"data\" give its key/value \
417 pairs; for what=\"metadata\" only version and created_time matter."
418 .to_string(),
419 parameters: vec![
420 param(
421 "data",
422 "object",
423 "The secret's key/value pairs, e.g. {\"username\": \"app\", \"password\": \"...\"}",
424 false,
425 ),
426 param(
427 "version",
428 "number",
429 "The secret's version, a positive integer (default 1)",
430 false,
431 ),
432 param(
433 "created_time",
434 "string",
435 "When this version was written, RFC 3339 (default: now)",
436 false,
437 ),
438 param(
439 "custom_metadata",
440 "object",
441 "Optional string key/value metadata attached to the secret",
442 false,
443 ),
444 ],
445 example: json!({
446 "type": "send_vault_secret",
447 "data": {"username": "app", "password": "s3cr3t"},
448 "version": 3,
449 "created_time": "2026-09-01T10:00:00Z"
450 }),
451 log_template: Some(LogTemplate::new().with_info("-> Vault secret v{version}")),
452 }
453}
454
455fn send_vault_write_ok_action() -> ActionDefinition {
456 ActionDefinition {
457 name: "send_vault_write_ok".to_string(),
458 description: "Acknowledge a vault_write: the secret is stored as this version.".to_string(),
459 parameters: vec![
460 param(
461 "version",
462 "number",
463 "The version the write created: previous version + 1, or 1 for a new path",
464 false,
465 ),
466 param(
467 "created_time",
468 "string",
469 "When the write happened, RFC 3339 (default: now)",
470 false,
471 ),
472 ],
473 example: json!({"type": "send_vault_write_ok", "version": 2}),
474 log_template: Some(LogTemplate::new().with_info("-> Vault write v{version}")),
475 }
476}
477
478fn send_vault_list_action() -> ActionDefinition {
479 ActionDefinition {
480 name: "send_vault_list".to_string(),
481 description: "Answer a vault_list with the names directly under the path.".to_string(),
482 parameters: vec![param(
483 "keys",
484 "array",
485 "Names directly under the listed path; a folder ends with '/', e.g. [\"db\", \"keys/\"]",
486 true,
487 )],
488 example: json!({"type": "send_vault_list", "keys": ["db", "stripe", "keys/"]}),
489 log_template: Some(LogTemplate::new().with_info("-> Vault list")),
490 }
491}
492
493fn send_vault_error_action() -> ActionDefinition {
494 ActionDefinition {
495 name: "send_vault_error".to_string(),
496 description: "Refuse a request in Vault's error shape. 404 with no errors is 'no value \
497 found'; 403 [\"permission denied\"] is a bad or missing token."
498 .to_string(),
499 parameters: vec![
500 param(
501 "status",
502 "number",
503 "HTTP status: 404 not found, 403 permission denied, 400 bad request",
504 false,
505 ),
506 param(
507 "errors",
508 "array",
509 "Error strings the client prints, e.g. [\"permission denied\"]; empty for a 404",
510 false,
511 ),
512 ],
513 example: json!({"type": "send_vault_error", "status": 403, "errors": ["permission denied"]}),
514 log_template: Some(LogTemplate::new().with_info("-> Vault error {status}")),
515 }
516}