Skip to main content

khive_runtime/
secret_gate.rs

1//! Write-time secret detection gate.
2//!
3//! Scans caller-supplied content strings before any storage write. A match
4//! causes a hard `RuntimeError::SecretDetected` that names the detector and
5//! carries a masked excerpt internally. Its display names the rule and trigger
6//! without echoing any candidate text.
7//!
8//! Scope: **credentials only** — API keys, tokens, private keys, passwords,
9//! and connection strings with embedded credentials. General PII (emails,
10//! phone numbers, company names) is intentionally NOT blocked.
11//!
12//! Detection is layered, cheap-first:
13//! 1. **Known-prefix / known-shape patterns** — AWS AKIA/ASIA, GitHub tokens,
14//!    OpenAI `sk-proj-`, Anthropic `sk-ant-`, Stripe live keys, Fly.io tokens,
15//!    Vercel secrets, Slack `xox*`, JWT triples, PEM private-key headers, Age
16//!    secret keys, URL userinfo (`scheme://user:pass@`).
17//! 2. **High-entropy token heuristic** — base64/hex/base64url runs ≥ 24 chars
18//!    near a trigger word (key, secret, password, credential, bearer, auth,
19//!    apikey, api_key, access_key, private_key). A standalone `token` still
20//!    triggers opaque entropy detection, but does not by itself label a UUID
21//!    as a credential; compound identifiers such as `tokenizer_*` and
22//!    `token_count` remain excluded.
23//!
24//! Credential-shaped labels and assignments dominate the allowlist below.
25//! Public VCS revisions and plausible file paths remain exempt in ordinary
26//! technical prose, while path segments are still scanned independently.
27//!
28//! Full exemption rules (hex/UUID/SRI-hash passes, non-ASCII token
29//! delimiting, structured-identifier decomposition, trigger word-boundary
30//! matching, the underscore-boundary asymmetry between bare trigger words and
31//! the word `token`, and the adversarial-corpus rationale for why some
32//! false positives are accepted) are documented in full in
33//! `docs/api/secret_gate.md#module-level-detection-algorithm` — read that before
34//! changing any detection or exemption logic in this file.
35//!
36//! The caller-visible block message (`SecretMatch`'s `Display` impl) also
37//! carries actionable guidance (`block_guidance`) to split or reword the
38//! flagged token.
39//!
40//! A production-corpus replay harness (`corpus_replay`, `#[ignore]`d, run via
41//! `KHIVE_REPLAY_DB=<path> cargo test ... -- --ignored --nocapture`) measures
42//! the detector's block rate against real note/entity content; see the
43//! harness's own output for current numbers rather than a point-in-time count
44//! here, which would drift as the corpus changes. A checked-in, sanitized
45//! snapshot of that replay (per-detector block counts and sha256 digests of
46//! blocked content, never the content itself) lives at
47//! `tests/data/secret_gate_corpus_manifest.md`, generated by
48//! `corpus_replay::generate_corpus_manifest`.
49//! A replay that is identical before and after a change certifies preservation
50//! on the corpus population only, never on the shape class the change opens;
51//! that class needs its own before/after arms.
52
53use crate::error::{RuntimeError, RuntimeResult};
54
55mod bridge_fragments;
56mod credential_triggers;
57mod entropy;
58mod false_positive_filters;
59mod known_patterns;
60mod masking;
61mod submitted_atom_digest;
62
63use bridge_fragments::{
64    bridge_fragment_chain, contains_bounded_word, contains_word, is_bridge_fragment_shape,
65    trailing_bridge_fragment_cut,
66};
67#[cfg(test)]
68use credential_triggers::LOOKUP_KEY_LABELS;
69use credential_triggers::{
70    assignment_credential_trigger, build_match, extract_token, find_trigger,
71    inline_credential_trigger, is_assignment_label_gap, is_base64_content_hash,
72    is_lookup_key_label, is_pure_hex, is_structured_identifier, is_uuid_canonical, shannon_entropy,
73    strip_delimiters, value_candidates, wrapper_strip_repeated,
74};
75#[cfg(test)]
76use entropy::TRIGGER_WINDOW;
77use entropy::{
78    check_entropy_heuristic, tokenize_entropy_tokens, EntropyScanContext, COMPOUND_TRIGGER_WORDS,
79    ENTROPY_THRESHOLD, HEX_CREDENTIAL_LENGTHS, MAX_BRIDGE_FRAGMENTS, MAX_BRIDGE_GLUE_TOKENS,
80    MIN_BRIDGE_FRAGMENT_LEN, MIN_ENTROPY_LEN, TRIGGER_WORDS,
81};
82use false_positive_filters::{
83    after_last_sentence_boundary, before_first_sentence_boundary,
84    has_clause_credential_label_with_inline, has_direct_repository_credential_label_with_inline,
85    has_immediate_credential_label, is_aws_resource_name, is_environment_name,
86    is_git_revision_reference, is_labeled_sha256_digest, is_latex_fragment_without_credential_run,
87    is_latex_prose_macro, is_plausible_file_path, is_prose_code_reference,
88    is_repository_revision_reference, is_vcs_marker_before_hex, normalized_hex_credential_span,
89    ClauseValueKind,
90};
91#[cfg(test)]
92use false_positive_filters::{is_clause_narrative_gerund, is_clause_narrative_participle};
93use known_patterns::check_known_patterns;
94#[cfg(doc)]
95use known_patterns::find_url_userinfo;
96#[cfg(test)]
97use known_patterns::{find_prefix_token, is_filename_shaped_prefix_match, PREFIX_DETECTORS};
98pub use masking::{
99    bounded_masked_log_text, mask_bounded, mask_secrets, BoundedMask, MASK_WINDOW_CHARS,
100};
101#[cfg(test)]
102use masking::{collect_mask_spans, MAX_LOG_TEXT_OUTPUT_CHARS, TRUNCATION_MARKER};
103use masking::{extend_across_invisible_bridge, MAX_LOG_TEXT_MASK_INPUT_CHARS};
104
105pub use submitted_atom_digest::masked_submitted_atom_digest_v1;
106
107// ─── Public API ──────────────────────────────────────────────────────────────
108
109/// Returned when a write would store credential-looking content.
110///
111/// Carries the detector name and a masked excerpt (`first6...Nchars`).  The
112/// full candidate is never stored in the error.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SecretMatch {
115    /// Human-readable name of the detector that fired.
116    pub detector: &'static str,
117    /// Canonical trigger from the matched context; known-prefix rules need none.
118    pub trigger: Option<&'static str>,
119    /// `first6...N` — the first 6 chars of the match followed by the total length.
120    pub masked: String,
121    /// Which record and field the match came from. `None` only where the
122    /// caller scanned exactly one string and nothing else, so there is one
123    /// candidate. A single-record verb that scans several fields still needs
124    /// this: the writer sees one refusal and cannot tell whether it was the
125    /// name, the content, a tag or a property that matched.
126    pub location: Option<String>,
127}
128
129impl std::fmt::Display for SecretMatch {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        write!(f, "content matches secret pattern {}", self.detector)?;
132        if let Some(trigger) = self.trigger {
133            write!(f, " near '{trigger}'")?;
134        }
135        if let Some(location) = &self.location {
136            write!(f, " in {location}")?;
137        }
138        write!(f, ". {}", block_guidance(self.detector))
139    }
140}
141
142/// Actionable, caller-visible guidance for a hard block, keyed by detector
143/// name. If the content genuinely is a credential, remove it. If it
144/// is not — the common case for the detectors below, which key off SHAPE
145/// near a trigger word rather than a known credential prefix — the fix is to
146/// break up the flagged token so it no longer reads as one contiguous
147/// high-entropy value: separate it from words like key/secret/auth/token with
148/// a sentence or paragraph boundary, or use an explicit repository revision
149/// reference for a source hash.
150fn block_guidance(detector: &'static str) -> &'static str {
151    match detector {
152        "url-userinfo" => {
153            "Placeholders in URL credential positions still match this pattern. \
154             Replace the whole URL with an environment-variable name or config key, \
155             or remove the entire user/password segment before writing."
156        }
157        "high-entropy-token"
158        | "uuid-near-trigger"
159        | "content-hash-near-trigger"
160        | "hex-credential-token" => {
161            "If this is a real credential, remove it before writing. If it is not \
162             (e.g. a file path, UUID, or hash that happens to sit near a word like \
163             key/secret/auth/token), put the candidate in a separate sentence or \
164             paragraph from those words, or express a source hash as an explicit \
165             commit/revision reference."
166        }
167        _ => {
168            "If this is a real credential, remove it before writing; store secrets \
169              in an env var or secrets manager instead."
170        }
171    }
172}
173
174/// Hard-block content from being written.
175///
176/// Returns `Err(RuntimeError::SecretDetected)` on the first match found, or
177/// `Ok(())` if no secret pattern fires.
178pub fn check(content: &str) -> RuntimeResult<()> {
179    if let Some(m) = scan(content) {
180        return Err(RuntimeError::SecretDetected(m));
181    }
182    Ok(())
183}
184
185/// Recursively scan a JSON value for credential-shaped strings.
186///
187/// Walks every string leaf (object values, array elements, nested objects).
188/// Returns `Err(RuntimeError::SecretDetected)` on the first match found.
189/// `None` / null / numeric / boolean JSON values are skipped.
190pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
191    scan_json_value(value)
192}
193
194/// Scan a string-tagged slice (entity/note tags).
195///
196/// Each tag string is scanned individually.
197pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
198    for tag in tags {
199        check(tag)?;
200    }
201    Ok(())
202}
203
204/// Name the scope and field a refusal came from.
205///
206/// A batch verb scans each record and returns on the first refusal, so the
207/// caller gets ONE error for N records. Without this the error names only the
208/// matched text, which by construction is text the caller cannot find: it sits
209/// in whichever sibling record refused, and every other record in the call is
210/// rejected with it (khive #2605). Pass through anything that is not a gate
211/// refusal unchanged — this adds identity, it does not reclassify.
212///
213/// `record` names the record the field belongs to, as the caller submitted it:
214/// `entity`, `note`, `task`, `proposal`, `message`, indexed when it came from a
215/// batch (`note[2]`). It answers where in the submitted payload the writer
216/// should look, which is the question a refused writer actually asks.
217pub fn locate<T>(result: RuntimeResult<T>, record: &str, field: &str) -> RuntimeResult<T> {
218    result.map_err(|error| match error {
219        RuntimeError::SecretDetected(matched) => RuntimeError::SecretDetected(SecretMatch {
220            location: Some(format!("{record}.{field}")),
221            ..matched
222        }),
223        other => other,
224    })
225}
226
227/// `check` that names where it looked. `record` is the record noun the caller
228/// submitted, indexed inside a batch; `field` is the field of it that was scanned.
229pub fn check_at(content: &str, record: &str, field: &str) -> RuntimeResult<()> {
230    locate(check(content), record, field)
231}
232
233/// `check_json` that names where it looked. The location is the field holding
234/// the JSON, not the path of the string leaf that matched inside it.
235pub fn check_json_at(value: &serde_json::Value, record: &str, field: &str) -> RuntimeResult<()> {
236    locate(check_json(value), record, field)
237}
238
239/// `check_tags` that names where it looked.
240pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> {
241    locate(check_tags(tags), record, field)
242}
243
244// ─── Reserved property keys ──────────────────────────────────────────────────
245
246/// Top-level JSON property key reserved for runtime-owned exemption state.
247///
248/// No caller may create, replace, merge, or remove this key through any
249/// properties-bearing write path — ADR-115 Amendment 1 §3. Reservation binds
250/// unconditionally: the runtime does not yet stamp any record with this key
251/// (the finalizer that would do so is a separate, later increment), so no
252/// caller-supplied occurrence of it can ever be a legitimate echo of
253/// persisted state. Only the exact top-level key is reserved; the same
254/// spelling nested inside an object *value* is ordinary content and remains
255/// subject to [`check_json`], never a posture mutation.
256pub const RESERVED_SECRET_GATE_KEY: &str = "khive:secret_gate";
257pub const RESERVED_WEB_RECEIPT_KEY: &str = "khive:web_receipt";
258pub const WEB_RECEIPT_PROVENANCE_VALUE: &str = "v1";
259
260/// Reject a caller-supplied top-level `khive:secret_gate` property key.
261///
262/// Call this before any diff, merge, or storage preparation touches
263/// caller-supplied `properties` on any properties-bearing write path —
264/// create, patch update, or full replace. Returns `Ok(())` when `properties`
265/// is absent, is not a JSON object, or does not name the reserved key at the
266/// top level.
267///
268/// This is the shared validator for both the ADR-115 reservation and web
269/// receipt provenance. Every properties-bearing generic write path calls it
270/// before a merge or full-row replacement.
271pub fn reject_reserved_secret_gate_property(
272    properties: Option<&serde_json::Value>,
273) -> RuntimeResult<()> {
274    if let Some(serde_json::Value::Object(map)) = properties {
275        if map.contains_key(RESERVED_SECRET_GATE_KEY) {
276            return Err(RuntimeError::InvalidInput(format!(
277                "property key `{RESERVED_SECRET_GATE_KEY}` is runtime-owned and cannot be \
278                 created, replaced, merged, or removed by callers"
279            )));
280        }
281        if map.contains_key(RESERVED_WEB_RECEIPT_KEY) {
282            return Err(RuntimeError::InvalidInput(format!(
283                "property key `{RESERVED_WEB_RECEIPT_KEY}` is web-pack-owned and cannot be \
284                 created, replaced, merged, or removed by callers"
285            )));
286        }
287    }
288    Ok(())
289}
290
291fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
292    match value {
293        serde_json::Value::String(s) => check(s),
294        serde_json::Value::Array(arr) => {
295            for v in arr {
296                scan_json_value(v)?;
297            }
298            Ok(())
299        }
300        serde_json::Value::Object(map) => {
301            for (k, v) in map {
302                // Scan both the key (a credential can appear as a JSON key name)
303                // and the value recursively.
304                check(k)?;
305                scan_json_value(v)?;
306            }
307            Ok(())
308        }
309        _ => Ok(()),
310    }
311}
312
313// ─── Scanner ─────────────────────────────────────────────────────────────────
314
315/// Marker substituted for a detected secret span by [`mask_secrets`].
316const REDACTION_MARKER: &str = "***MASKED***";
317
318/// Maximum cumulative bytes revisited by the per-pass detector sweeps while masking
319/// one input. Entropy tokens are materialized once, but resuming inside a token
320/// rebuilds its member candidates and revisits their context, so that token's
321/// full prefix is also charged. This permits two full-size passes over the 1 MiB
322/// ASCII log-input case; the first pass is always allowed for larger or multibyte
323/// callers. Once exhausted, the remainder is redacted wholesale.
324const MAX_MASK_SCAN_WORK_BYTES: usize = MAX_LOG_TEXT_MASK_INPUT_CHARS * 2;
325
326#[cfg(test)]
327thread_local! {
328    static ENTROPY_TOKENIZATION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
329}
330
331/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
332///
333/// The matched slice borrows from `text`, so the caller can recover its byte
334/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
335/// place while [`scan`] only needs the masked excerpt.
336///
337/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
338/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
339/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
340/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
341/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
342#[cfg(test)]
343fn scan_match(text: &str) -> Option<(&str, &'static str)> {
344    let context = EntropyScanContext::new(text);
345    scan_from(text, 0, &context)
346}
347
348/// Like [`scan_match`], but only returns secrets whose span starts at or after
349/// `from`, while still evaluating Layer-2 trigger context against the FULL
350/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
351/// entropy token is detected even when its only trigger word sits to the left of
352/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
353/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
354/// pointer arithmetic against the original `text` base stay absolute. The
355/// pre-tokenized entropy view is shared by all passes.
356fn scan_from<'a>(
357    text: &'a str,
358    from: usize,
359    context: &EntropyScanContext<'a>,
360) -> Option<(&'a str, &'static str)> {
361    scan_from_with_trigger(text, from, context).map(|(slice, detector, _)| (slice, detector))
362}
363
364fn scan_from_with_trigger<'a>(
365    text: &'a str,
366    from: usize,
367    context: &EntropyScanContext<'a>,
368) -> Option<(&'a str, &'static str, Option<&'static str>)> {
369    let mut best =
370        check_known_patterns(&text[from..]).map(|(slice, detector)| (slice, detector, None));
371    if let Some(candidate) = check_entropy_heuristic(text, from, context) {
372        if best
373            .as_ref()
374            .is_none_or(|current| candidate.0.as_ptr() < current.0.as_ptr())
375        {
376            best = Some(candidate);
377        }
378    }
379    best
380}
381
382/// Replace `best` with `cand` when `cand` starts earlier in the original text
383/// (`base` is the start address of that text). On a tie the incumbent wins, so
384/// callers offer more-specific detectors first. This is what makes
385/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
386/// rather than the first detector that happens to match anywhere.
387fn keep_leftmost<'a>(
388    best: &mut Option<(&'a str, &'static str)>,
389    cand: Option<(&'a str, &'static str)>,
390    base: usize,
391) {
392    if let Some((slice, name)) = cand {
393        let start = slice.as_ptr() as usize - base;
394        let replace = match *best {
395            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
396            None => true,
397        };
398        if replace {
399            *best = Some((slice, name));
400        }
401    }
402}
403
404/// Return the first `SecretMatch` found in `text`, or `None`.
405fn scan(text: &str) -> Option<SecretMatch> {
406    let context = EntropyScanContext::new(text);
407    scan_from_with_trigger(text, 0, &context).map(|(slice, detector, trigger)| {
408        let mut matched = build_match(detector, slice);
409        matched.trigger = trigger;
410        matched
411    })
412}
413
414/// A named redact-not-block surface whose contract is intentionally separate
415/// from manifest-backed write admission (ADR-115 Amendment 2).
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum RedactionSurface {
418    /// Git ingestion stores only masked commit, issue, and pull-request text.
419    GitIngest,
420    /// Session mirroring stores only masked `text` and `raw` projections.
421    SessionMirror,
422    /// MCP diagnostics are bounded caller-visible transport data, not records.
423    McpDiagnostic,
424    /// The kg `scan` verb's masked preview: caller-visible, never stored.
425    GateProbe,
426}
427
428/// Whether a redaction surface can consume a secret-gate exemption.
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum RedactionSurfaceMode {
431    /// Always apply the canonical masker; never synthesize a stamp or event.
432    PermanentMaskOnly,
433}
434
435/// Machine-readable contract for a named redaction surface.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct RedactionSurfaceContract {
438    pub mode: RedactionSurfaceMode,
439    /// Durable target containing the masked result, if this is a write surface.
440    pub final_stored_target: Option<&'static str>,
441    /// Reserved stamp location; absent for permanent mask-only surfaces.
442    pub stamp_property: Option<&'static str>,
443    /// Atomic exemption-success event; absent when admission cannot occur.
444    pub atomic_success_event: Option<&'static str>,
445}
446
447/// Final stored target for [`RedactionSurface::GitIngest`] — see
448/// [`redaction_surface_contract`].
449pub const GIT_INGEST_STORED_TARGET: &str = "final git-ingest entity/note fields";
450
451/// Final stored target for [`RedactionSurface::SessionMirror`] — see
452/// [`redaction_surface_contract`]. Names every column the session mirror
453/// writes a masked provider-export projection into, not just the message
454/// body columns. `cwd`/`git_branch` live only on `sessions`, keyed per
455/// session — `session_messages` carries no such columns of its own (see the
456/// `session_messages` DDL in `khive-pack-session`).
457pub const SESSION_MIRROR_STORED_TARGET: &str =
458    "session_messages.text, session_messages.raw, sessions.cwd, sessions.git_branch, \
459     and sessions.slug";
460
461/// Return the closed contract for a named redact-not-block surface.
462pub const fn redaction_surface_contract(surface: RedactionSurface) -> RedactionSurfaceContract {
463    let final_stored_target = match surface {
464        RedactionSurface::GitIngest => Some(GIT_INGEST_STORED_TARGET),
465        RedactionSurface::SessionMirror => Some(SESSION_MIRROR_STORED_TARGET),
466        RedactionSurface::McpDiagnostic | RedactionSurface::GateProbe => None,
467    };
468
469    RedactionSurfaceContract {
470        mode: RedactionSurfaceMode::PermanentMaskOnly,
471        final_stored_target,
472        stamp_property: None,
473        atomic_success_event: None,
474    }
475}
476
477/// Apply the canonical detector to a named permanent mask-only surface.
478///
479/// This wrapper makes the non-admission decision executable at each call site:
480/// it has no manifest input, cannot return an exemption outcome, and cannot
481/// synthesize the runtime-owned `khive:secret_gate` property or success event.
482pub fn mask_for_redaction_surface(
483    surface: RedactionSurface,
484    text: &str,
485) -> std::borrow::Cow<'_, str> {
486    match redaction_surface_contract(surface).mode {
487        RedactionSurfaceMode::PermanentMaskOnly => mask_secrets(text),
488    }
489}
490
491#[cfg(test)]
492#[path = "secret_gate/issue_2655_tests.rs"]
493mod issue_2655_tests;
494
495// ─── Tests ───────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498#[path = "secret_gate_tests.rs"]
499mod tests;
500
501// ─── Corpus replay harness (manual, opt-in) ─────────────────────────────────
502//
503// Measures how many real note/entity strings the gate blocks, so a detector
504// change can be evaluated against production content rather than intuition
505// (see the module doc). Opens the target database
506// STRICTLY read-only (`SQLITE_OPEN_READ_ONLY`) and never mutates it. Point
507// `KHIVE_REPLAY_DB` at a copy or a live KG database file path; the harness
508// never writes, locks aggressively, or deletes anything.
509//
510// Run with: `KHIVE_REPLAY_DB=/path/to/khive.db cargo test -p khive-runtime \
511//   --release -- --ignored --nocapture corpus_replay`
512#[cfg(test)]
513#[path = "secret_gate/corpus_replay_tests.rs"]
514mod corpus_replay;