Skip to main content

netget/server/vault/
actions.rs

1//! Vault KV v2 actions.
2//!
3//! Three events — read (data or metadata), write, list — and four actions. The model answers
4//! with key/value maps, key names and version numbers; NetGet wraps them in the envelope the
5//! Vault client decodes (`request_id`, `lease_*`, the KV v2 `data`/`metadata` split).
6//!
7//! The token is never an event field. The model sees `token_present`,
8//! `token_matches_configured` and `token_configured`, which is enough to refuse with a 403 and
9//! never enough to leak the credential into a prompt, a log or a script's stdin.
10
11use 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
29/// `GET <mount>/data/<path>` (what = "data") or `GET <mount>/metadata/<path>` (what =
30/// "metadata").
31pub 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
50/// `PUT`/`POST <mount>/data/<path>`.
51pub 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
69/// `LIST <mount>/metadata/<path>` (or `GET …?list=true`).
70pub 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
84/// Vault protocol handler.
85pub 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            // Beta on evidence: tests/server/vault/real_client_test.rs drives HashiCorp's own
146            // vault CLI through status, kv put/get/list/metadata get, a 404, a 403 and a 405,
147            // asserting it prints exactly the handler's values, and hard-fails when the binary
148            // is absent. Not Stable: one client, no fuzz target, no pcap-oracle test, KV v2 only.
149            .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            // LLM failure: 503 + Retry-After (overloaded) or 500, each as Vault's
182            // {"errors": [category]} - the CLI prints it after "Error making API request".
183            .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}