Skip to main content

tsafe_core/
tooling_inventory.rs

1//! Repo-local secret inventory for agent-facing tooling.
2//!
3//! `.tsafe/tooling/keys.ini` records secret slots, purpose, consumer, and
4//! rotation intent. It never stores secret values. MCP and CLI callers use the
5//! same parser/writer here so suggestions and validation share one contract.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use uuid::Uuid;
16
17use crate::vault::validate_secret_key;
18
19pub const KEYS_SCHEMA: &str = "tsafe.tooling.keys.v1";
20pub const POLICY_SCHEMA: &str = "tsafe.tooling_policy.v1";
21
22pub type Result<T> = std::result::Result<T, ToolingInventoryError>;
23
24#[derive(Debug, Error)]
25pub enum ToolingInventoryError {
26    #[error("{0}")]
27    InvalidInput(String),
28    #[error("io error: {0}")]
29    Io(#[from] std::io::Error),
30    #[error("json error: {0}")]
31    Json(#[from] serde_json::Error),
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct InventoryEntry {
36    pub section: String,
37    pub key: String,
38    pub purpose: String,
39    pub consumer: String,
40    pub rotation: String,
41    pub line: usize,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ToolingInitReport {
46    pub created: bool,
47    pub root: PathBuf,
48    pub tooling_dir: PathBuf,
49    pub keys_path: PathBuf,
50    pub policy_path: PathBuf,
51    pub readme_path: PathBuf,
52    pub namespace: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct InventoryCheckReport {
57    pub ok: bool,
58    pub root: PathBuf,
59    pub keys_path: PathBuf,
60    pub namespace: Option<String>,
61    pub entries: Vec<InventoryEntry>,
62    pub warnings: Vec<String>,
63    pub errors: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct SuggestKey {
68    pub key: String,
69    pub purpose: String,
70    pub consumer: String,
71    pub rotation: String,
72    pub section: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SuggestKeysRequest {
77    pub namespace: String,
78    pub source: String,
79    pub reason: String,
80    pub apply: bool,
81    pub keys: Vec<SuggestKey>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SuggestKeysReport {
86    pub suggestion_id: String,
87    pub root: PathBuf,
88    pub keys_path: PathBuf,
89    pub suggestions_path: PathBuf,
90    pub receipt_path: Option<PathBuf>,
91    pub namespace: String,
92    pub added_keys: Vec<String>,
93    pub existing_keys: Vec<String>,
94    pub preview_keys: Vec<String>,
95    pub applied: bool,
96    pub vault_values_written: bool,
97}
98
99#[derive(Debug, Clone)]
100struct ParsedInventory {
101    namespace: Option<String>,
102    entries: Vec<InventoryEntry>,
103    warnings: Vec<String>,
104    errors: Vec<String>,
105}
106
107pub fn tooling_dir(root: &Path) -> PathBuf {
108    root.join(".tsafe").join("tooling")
109}
110
111pub fn keys_path(root: &Path) -> PathBuf {
112    tooling_dir(root).join("keys.ini")
113}
114
115pub fn policy_path(root: &Path) -> PathBuf {
116    tooling_dir(root).join("policy.toml")
117}
118
119pub fn suggestions_path(root: &Path) -> PathBuf {
120    tooling_dir(root).join("suggestions.jsonl")
121}
122
123pub fn receipts_dir(root: &Path) -> PathBuf {
124    tooling_dir(root).join("receipts")
125}
126
127pub fn readme_path(root: &Path) -> PathBuf {
128    tooling_dir(root).join("README.md")
129}
130
131pub fn init_tooling(
132    root: &Path,
133    namespace: Option<&str>,
134    force: bool,
135) -> Result<ToolingInitReport> {
136    let root = normalize_root(root);
137    let namespace = normalize_namespace(namespace.unwrap_or(&default_namespace(&root)))?;
138    let tooling = tooling_dir(&root);
139    fs::create_dir_all(&tooling)?;
140    fs::create_dir_all(receipts_dir(&root))?;
141
142    let keys = keys_path(&root);
143    let policy = policy_path(&root);
144    let readme = readme_path(&root);
145    let mut created = false;
146
147    if force || !keys.exists() {
148        fs::write(&keys, render_keys_ini(&root, &namespace))?;
149        created = true;
150    }
151    if force || !policy.exists() {
152        fs::write(&policy, render_policy(&namespace))?;
153        created = true;
154    }
155    if force || !readme.exists() {
156        fs::write(&readme, render_readme())?;
157        created = true;
158    }
159
160    Ok(ToolingInitReport {
161        created,
162        root,
163        tooling_dir: tooling,
164        keys_path: keys,
165        policy_path: policy,
166        readme_path: readme,
167        namespace,
168    })
169}
170
171pub fn check_inventory(root: &Path) -> Result<InventoryCheckReport> {
172    let root = normalize_root(root);
173    let keys = keys_path(&root);
174    if !keys.exists() {
175        return Ok(InventoryCheckReport {
176            ok: false,
177            root,
178            keys_path: keys,
179            namespace: None,
180            entries: Vec::new(),
181            warnings: Vec::new(),
182            errors: vec!["missing .tsafe/tooling/keys.ini".to_string()],
183        });
184    }
185
186    let text = fs::read_to_string(&keys)?;
187    let parsed = parse_keys_ini(&text);
188    let ok = parsed.errors.is_empty();
189
190    Ok(InventoryCheckReport {
191        ok,
192        root,
193        keys_path: keys,
194        namespace: parsed.namespace,
195        entries: parsed.entries,
196        warnings: parsed.warnings,
197        errors: parsed.errors,
198    })
199}
200
201pub fn suggest_keys(root: &Path, request: SuggestKeysRequest) -> Result<SuggestKeysReport> {
202    if request.keys.is_empty() {
203        return Err(ToolingInventoryError::InvalidInput(
204            "at least one suggested key is required".to_string(),
205        ));
206    }
207    validate_comment_field("source", &request.source)?;
208    validate_comment_field("reason", &request.reason)?;
209
210    let root = normalize_root(root);
211    let namespace = normalize_namespace(&request.namespace)?;
212    if !keys_path(&root).exists() {
213        init_tooling(&root, Some(&namespace), false)?;
214    }
215
216    let check = check_inventory(&root)?;
217    if !check.ok {
218        return Err(ToolingInventoryError::InvalidInput(format!(
219            "keys.ini is not valid: {}",
220            check.errors.join("; ")
221        )));
222    }
223
224    let existing: BTreeSet<String> = check
225        .entries
226        .iter()
227        .map(|entry| entry.key.clone())
228        .collect();
229    let suggestion_id = format!("sug_{}", Uuid::new_v4().simple());
230    let mut added = Vec::new();
231    let mut existing_keys = Vec::new();
232    let mut preview = Vec::new();
233    let mut by_section: BTreeMap<String, Vec<(String, &SuggestKey)>> = BTreeMap::new();
234
235    for item in &request.keys {
236        let full_key = normalize_suggested_key(&namespace, &item.key)?;
237        if has_plaintext_secret_shape(&item.purpose)
238            || has_plaintext_secret_shape(&item.consumer)
239            || has_plaintext_secret_shape(&item.rotation)
240        {
241            return Err(ToolingInventoryError::InvalidInput(
242                "suggestions may describe secret slots but must not include secret values"
243                    .to_string(),
244            ));
245        }
246        validate_metadata_field("purpose", &item.purpose)?;
247        validate_metadata_field("consumer", &item.consumer)?;
248        validate_metadata_field("rotation", &item.rotation)?;
249        if existing.contains(&full_key) {
250            existing_keys.push(full_key);
251            continue;
252        }
253        preview.push(full_key.clone());
254        if request.apply {
255            added.push(full_key.clone());
256            let section = item
257                .section
258                .as_deref()
259                .map(sanitize_section)
260                .unwrap_or_else(|| "suggested".to_string());
261            by_section
262                .entry(section)
263                .or_default()
264                .push((full_key, item));
265        }
266    }
267
268    if request.apply && !by_section.is_empty() {
269        append_to_keys_ini(
270            &root,
271            &suggestion_id,
272            &request.source,
273            &request.reason,
274            by_section,
275        )?;
276    }
277
278    let suggestions = suggestions_path(&root);
279    append_suggestion_record(
280        &root,
281        &suggestion_id,
282        &request,
283        &preview,
284        &added,
285        &existing_keys,
286    )?;
287
288    let receipt = if request.apply {
289        Some(write_receipt(
290            &root,
291            &suggestion_id,
292            &request,
293            &added,
294            &existing_keys,
295        )?)
296    } else {
297        None
298    };
299
300    Ok(SuggestKeysReport {
301        suggestion_id,
302        root: root.clone(),
303        keys_path: keys_path(&root),
304        suggestions_path: suggestions,
305        receipt_path: receipt,
306        namespace,
307        added_keys: added,
308        existing_keys,
309        preview_keys: preview,
310        applied: request.apply,
311        vault_values_written: false,
312    })
313}
314
315fn normalize_root(root: &Path) -> PathBuf {
316    if root.as_os_str().is_empty() {
317        PathBuf::from(".")
318    } else {
319        root.to_path_buf()
320    }
321}
322
323fn default_namespace(root: &Path) -> String {
324    let name = root
325        .file_name()
326        .and_then(|s| s.to_str())
327        .filter(|s| !s.trim().is_empty())
328        .unwrap_or("repo");
329    format!("{}/", sanitize_key_part(name))
330}
331
332fn normalize_namespace(raw: &str) -> Result<String> {
333    let trimmed = raw.trim().trim_start_matches('/').replace('\\', "/");
334    if trimmed.is_empty() {
335        return Err(ToolingInventoryError::InvalidInput(
336            "namespace must not be empty".to_string(),
337        ));
338    }
339    let namespace = if trimmed.ends_with('/') {
340        trimmed
341    } else {
342        format!("{trimmed}/")
343    };
344    if namespace.contains("//")
345        || namespace
346            .trim_end_matches('/')
347            .split('/')
348            .any(|part| part.trim().is_empty())
349    {
350        return Err(ToolingInventoryError::InvalidInput(format!(
351            "namespace '{namespace}' is not valid"
352        )));
353    }
354    validate_secret_key(namespace.trim_end_matches('/')).map_err(|err| {
355        ToolingInventoryError::InvalidInput(format!("namespace '{namespace}' is not valid: {err}"))
356    })?;
357    Ok(namespace)
358}
359
360fn normalize_suggested_key(namespace: &str, raw: &str) -> Result<String> {
361    let key = raw.trim().trim_start_matches('/').replace('\\', "/");
362    if key.is_empty() {
363        return Err(ToolingInventoryError::InvalidInput(
364            "suggested key must not be empty".to_string(),
365        ));
366    }
367    let full = if key.starts_with(namespace) {
368        key
369    } else if key.contains('/') {
370        return Err(ToolingInventoryError::InvalidInput(format!(
371            "suggested key '{key}' is outside namespace '{namespace}'"
372        )));
373    } else {
374        format!("{namespace}{}", sanitize_key_part(&key))
375    };
376    if full.contains("//") || full.ends_with('/') {
377        return Err(ToolingInventoryError::InvalidInput(format!(
378            "suggested key '{full}' is not valid"
379        )));
380    }
381    validate_secret_key(&full).map_err(|err| {
382        ToolingInventoryError::InvalidInput(format!("suggested key '{full}' is not valid: {err}"))
383    })?;
384    Ok(full)
385}
386
387fn sanitize_key_part(raw: &str) -> String {
388    raw.chars()
389        .map(|ch| {
390            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
391                ch
392            } else {
393                '_'
394            }
395        })
396        .collect()
397}
398
399fn sanitize_section(raw: &str) -> String {
400    let section: String = raw
401        .trim()
402        .chars()
403        .map(|ch| {
404            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
405                ch
406            } else {
407                '-'
408            }
409        })
410        .filter(|ch| !ch.is_whitespace())
411        .collect();
412    if section.is_empty() {
413        "suggested".to_string()
414    } else {
415        section
416    }
417}
418
419fn validate_metadata_field(label: &str, raw: &str) -> Result<()> {
420    let value = raw.trim();
421    if value.is_empty() {
422        return Err(ToolingInventoryError::InvalidInput(format!(
423            "{label} must not be empty"
424        )));
425    }
426    if value.contains('|') || value.chars().any(|ch| ch.is_control()) {
427        return Err(ToolingInventoryError::InvalidInput(format!(
428            "{label} must not contain control characters or `|`"
429        )));
430    }
431    Ok(())
432}
433
434fn validate_comment_field(label: &str, raw: &str) -> Result<()> {
435    let value = raw.trim();
436    if value.is_empty() {
437        return Err(ToolingInventoryError::InvalidInput(format!(
438            "{label} must not be empty"
439        )));
440    }
441    if value.chars().any(|ch| ch.is_control()) {
442        return Err(ToolingInventoryError::InvalidInput(format!(
443            "{label} must not contain control characters"
444        )));
445    }
446    Ok(())
447}
448
449fn render_keys_ini(root: &Path, namespace: &str) -> String {
450    let repo = root.file_name().and_then(|s| s.to_str()).unwrap_or("repo");
451    format!(
452        r#"# tsafe secret inventory for {repo}
453# Namespace: {namespace}
454# Format: key = purpose | consumer | rotation
455# Values never belong in this file.
456
457[inventory]
458schema = {KEYS_SCHEMA}
459namespace = {namespace}
460
461[ci-cd-spn]
462# key/name = purpose | consumer | rotation
463# {namespace}ci_cd_app_id = SPN app ID | CI service connection | static
464
465[sql-bridge]
466# {namespace}sql_password = SQL password | runtime bridge | 365d KV policy
467
468[scim]
469# {namespace}scim_pat = SCIM PAT | provisioning app | manual rotation on expiry
470
471[ops-read-only]
472# {namespace}readonly_app_id = read-only SPN app ID | discovery scripts | static
473"#
474    )
475}
476
477fn render_policy(namespace: &str) -> String {
478    format!(
479        r#"schema = "{POLICY_SCHEMA}"
480
481[namespace]
482default = "{namespace}"
483allow = ["{namespace}"]
484
485[mcp]
486suggest_enabled = true
487auto_write_keys_ini = true
488auto_write_vault_values = false
489require_receipt = true
490reject_plaintext_values = true
491"#
492    )
493}
494
495fn render_readme() -> &'static str {
496    r#"# tsafe tooling inventory
497
498This folder records secret slots for this repository. It is safe to commit when
499it contains only key names, purpose, consumer, and rotation metadata.
500
501- `keys.ini` is the human inventory.
502- `policy.toml` controls agent/MCP suggestion behavior.
503- `suggestions.jsonl` is an append-only suggestion log.
504- `receipts/` contains one JSON receipt per applied suggestion.
505
506Do not put secret values, tokens, private keys, vault files, or dotenv files in
507this folder.
508"#
509}
510
511fn parse_keys_ini(text: &str) -> ParsedInventory {
512    let mut section: Option<String> = None;
513    let mut namespace = None;
514    let mut entries = Vec::new();
515    let mut warnings = Vec::new();
516    let mut errors = Vec::new();
517    let mut seen = BTreeSet::new();
518
519    for (idx, raw_line) in text.lines().enumerate() {
520        let line_no = idx + 1;
521        let line = raw_line.trim();
522        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
523            continue;
524        }
525        if line.starts_with('[') && line.ends_with(']') {
526            let name = line.trim_start_matches('[').trim_end_matches(']').trim();
527            if name.is_empty() {
528                errors.push(format!("line {line_no}: empty section name"));
529                section = None;
530            } else {
531                section = Some(name.to_string());
532            }
533            continue;
534        }
535
536        let current_section = section.clone().unwrap_or_else(|| "default".to_string());
537        let Some((lhs, rhs)) = line.split_once('=') else {
538            errors.push(format!(
539                "line {line_no}: expected `key = purpose | consumer | rotation`"
540            ));
541            continue;
542        };
543        let key = lhs.trim();
544        let value = rhs.trim();
545
546        if current_section == "inventory" {
547            match key {
548                "schema" if value != KEYS_SCHEMA => warnings.push(format!(
549                    "line {line_no}: schema '{value}' is not the current {KEYS_SCHEMA}"
550                )),
551                "namespace" => match normalize_namespace(value) {
552                    Ok(ns) => namespace = Some(ns),
553                    Err(err) => errors.push(format!("line {line_no}: {err}")),
554                },
555                _ => {}
556            }
557            continue;
558        }
559
560        let parts: Vec<&str> = value.split('|').map(str::trim).collect();
561        if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
562            errors.push(format!(
563                "line {line_no}: expected `key = purpose | consumer | rotation`"
564            ));
565            continue;
566        }
567        if key.is_empty() || !key.contains('/') {
568            errors.push(format!(
569                "line {line_no}: key '{key}' must include a namespace prefix"
570            ));
571            continue;
572        }
573        if let Err(err) = validate_secret_key(key) {
574            errors.push(format!("line {line_no}: key '{key}' is not valid: {err}"));
575            continue;
576        }
577        if has_plaintext_secret_shape(value) {
578            errors.push(format!(
579                "line {line_no}: row appears to contain a plaintext secret value"
580            ));
581            continue;
582        }
583        if !seen.insert(key.to_string()) {
584            errors.push(format!("line {line_no}: duplicate key '{key}'"));
585        }
586        if let Some(ns) = namespace.as_deref() {
587            if !key.starts_with(ns) {
588                errors.push(format!(
589                    "line {line_no}: key '{key}' is outside namespace '{ns}'"
590                ));
591            }
592        }
593
594        entries.push(InventoryEntry {
595            section: current_section,
596            key: key.to_string(),
597            purpose: parts[0].to_string(),
598            consumer: parts[1].to_string(),
599            rotation: parts[2].to_string(),
600            line: line_no,
601        });
602    }
603
604    if namespace.is_none() {
605        warnings.push("missing [inventory] namespace".to_string());
606    }
607
608    ParsedInventory {
609        namespace,
610        entries,
611        warnings,
612        errors,
613    }
614}
615
616fn has_plaintext_secret_shape(value: &str) -> bool {
617    let v = value.trim();
618    if v.contains("-----BEGIN ") || v.contains(" PRIVATE KEY-----") {
619        return true;
620    }
621    let lower = v.to_ascii_lowercase();
622    if lower.contains("github_pat_") || lower.contains("ghp_") || lower.contains("sk-") {
623        return true;
624    }
625    v.split(|ch: char| !ch.is_ascii_alphanumeric())
626        .any(|part| part.starts_with("AKIA") && part.len() >= 16)
627}
628
629fn append_to_keys_ini(
630    root: &Path,
631    suggestion_id: &str,
632    source: &str,
633    reason: &str,
634    by_section: BTreeMap<String, Vec<(String, &SuggestKey)>>,
635) -> Result<()> {
636    let path = keys_path(root);
637    let mut file = fs::OpenOptions::new()
638        .append(true)
639        .create(true)
640        .open(&path)?;
641    writeln!(
642        file,
643        "\n# tsafe suggestion {suggestion_id}: source={source}; reason={reason}"
644    )?;
645    for (section, rows) in by_section {
646        writeln!(file, "\n[{section}]")?;
647        for (full_key, item) in rows {
648            writeln!(
649                file,
650                "{full_key} = {} | {} | {}",
651                item.purpose.trim(),
652                item.consumer.trim(),
653                item.rotation.trim()
654            )?;
655        }
656    }
657    Ok(())
658}
659
660fn append_suggestion_record(
661    root: &Path,
662    suggestion_id: &str,
663    request: &SuggestKeysRequest,
664    preview: &[String],
665    added: &[String],
666    existing: &[String],
667) -> Result<()> {
668    let path = suggestions_path(root);
669    let mut file = fs::OpenOptions::new()
670        .create(true)
671        .append(true)
672        .open(path)?;
673    let record = serde_json::json!({
674        "schema": "tsafe.tooling.suggestion.v1",
675        "suggestion_id": suggestion_id,
676        "created_at": Utc::now().to_rfc3339(),
677        "source": request.source,
678        "reason": request.reason,
679        "apply": request.apply,
680        "preview_keys": preview,
681        "added_keys": added,
682        "existing_keys": existing,
683        "vault_values_written": false,
684    });
685    writeln!(file, "{}", serde_json::to_string(&record)?)?;
686    Ok(())
687}
688
689fn write_receipt(
690    root: &Path,
691    suggestion_id: &str,
692    request: &SuggestKeysRequest,
693    added: &[String],
694    existing: &[String],
695) -> Result<PathBuf> {
696    let dir = receipts_dir(root);
697    fs::create_dir_all(&dir)?;
698    let path = dir.join(format!("{suggestion_id}.json"));
699    let receipt = serde_json::json!({
700        "schema": "tsafe.tooling.receipt.v1",
701        "suggestion_id": suggestion_id,
702        "created_at": Utc::now().to_rfc3339(),
703        "source": request.source,
704        "reason": request.reason,
705        "added_keys": added,
706        "existing_keys": existing,
707        "keys_ini": keys_path(root),
708        "vault_values_written": false,
709    });
710    fs::write(&path, serde_json::to_string_pretty(&receipt)?)?;
711    Ok(path)
712}