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};
67use credential_triggers::{
68    assignment_credential_trigger, build_match, extract_token, find_trigger,
69    inline_credential_trigger, is_assignment_label_gap, is_base64_content_hash,
70    is_credential_token_label, is_lookup_key_label, is_pure_hex, is_structured_identifier,
71    is_uuid_canonical, shannon_entropy, strip_delimiters, value_candidates, wrapper_strip_repeated,
72};
73#[cfg(test)]
74use credential_triggers::{LOOKUP_KEY_LABELS, POSITION_TOKEN_LABELS, RECEIPT_TOKEN_LABELS};
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/// Scan the supplied note fields in content, name, then properties order.
240///
241/// Returns the first refusal unchanged, located under `record` (for example,
242/// `note[2].properties`). Optional fields are scanned only when present; JSON
243/// scanning uses the same structured-value rules as [`check_json_at`].
244///
245/// This is field scanning only. Callers retain responsibility for property
246/// derivation, reserved-key checks, embedding inputs, and write admission, in
247/// their existing order.
248pub fn check_note_fields_at(
249    content: &str,
250    name: Option<&str>,
251    properties: Option<&serde_json::Value>,
252    record: &str,
253) -> RuntimeResult<()> {
254    check_at(content, record, "content")?;
255    if let Some(name) = name {
256        check_at(name, record, "name")?;
257    }
258    if let Some(properties) = properties {
259        check_json_at(properties, record, "properties")?;
260    }
261    Ok(())
262}
263
264/// `check_tags` that names where it looked.
265pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> {
266    locate(check_tags(tags), record, field)
267}
268
269// ─── Reserved property keys ──────────────────────────────────────────────────
270
271/// Top-level JSON property key reserved for runtime-owned exemption state.
272///
273/// No caller may create, replace, merge, or remove this key through any
274/// properties-bearing write path — ADR-115 Amendment 1 §3. Reservation binds
275/// unconditionally: the runtime does not yet stamp any record with this key
276/// (the finalizer that would do so is a separate, later increment), so no
277/// caller-supplied occurrence of it can ever be a legitimate echo of
278/// persisted state. Only the exact top-level key is reserved; the same
279/// spelling nested inside an object *value* is ordinary content and remains
280/// subject to [`check_json`], never a posture mutation.
281pub const RESERVED_SECRET_GATE_KEY: &str = "khive:secret_gate";
282pub const RESERVED_WEB_RECEIPT_KEY: &str = "khive:web_receipt";
283pub const WEB_RECEIPT_PROVENANCE_VALUE: &str = "v1";
284
285/// Reject a caller-supplied top-level `khive:secret_gate` property key.
286///
287/// Call this before any diff, merge, or storage preparation touches
288/// caller-supplied `properties` on any properties-bearing write path —
289/// create, patch update, or full replace. Returns `Ok(())` when `properties`
290/// is absent, is not a JSON object, or does not name the reserved key at the
291/// top level.
292///
293/// This is the shared validator for both the ADR-115 reservation and web
294/// receipt provenance. Every properties-bearing generic write path calls it
295/// before a merge or full-row replacement.
296pub fn reject_reserved_secret_gate_property(
297    properties: Option<&serde_json::Value>,
298) -> RuntimeResult<()> {
299    if let Some(serde_json::Value::Object(map)) = properties {
300        if map.contains_key(RESERVED_SECRET_GATE_KEY) {
301            return Err(RuntimeError::InvalidInput(format!(
302                "property key `{RESERVED_SECRET_GATE_KEY}` is runtime-owned and cannot be \
303                 created, replaced, merged, or removed by callers"
304            )));
305        }
306        if map.contains_key(RESERVED_WEB_RECEIPT_KEY) {
307            return Err(RuntimeError::InvalidInput(format!(
308                "property key `{RESERVED_WEB_RECEIPT_KEY}` is web-pack-owned and cannot be \
309                 created, replaced, merged, or removed by callers"
310            )));
311        }
312    }
313    Ok(())
314}
315
316fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
317    match value {
318        serde_json::Value::String(s) => check(s),
319        serde_json::Value::Array(arr) => {
320            for v in arr {
321                scan_json_value(v)?;
322            }
323            Ok(())
324        }
325        serde_json::Value::Object(map) => {
326            for (k, v) in map {
327                // Scan both the key (a credential can appear as a JSON key name)
328                // and the value recursively.
329                check(k)?;
330                scan_json_value(v)?;
331            }
332            Ok(())
333        }
334        _ => Ok(()),
335    }
336}
337
338// ─── Scanner ─────────────────────────────────────────────────────────────────
339
340/// Marker substituted for a detected secret span by [`mask_secrets`].
341const REDACTION_MARKER: &str = "***MASKED***";
342
343/// Maximum cumulative bytes revisited by the per-pass detector sweeps while masking
344/// one input. Entropy tokens are materialized once, but resuming inside a token
345/// rebuilds its member candidates and revisits their context, so that token's
346/// full prefix is also charged. This permits two full-size passes over the 1 MiB
347/// ASCII log-input case; the first pass is always allowed for larger or multibyte
348/// callers. Once exhausted, the remainder is redacted wholesale.
349const MAX_MASK_SCAN_WORK_BYTES: usize = MAX_LOG_TEXT_MASK_INPUT_CHARS * 2;
350
351#[cfg(test)]
352thread_local! {
353    static ENTROPY_TOKENIZATION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
354}
355
356/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
357///
358/// The matched slice borrows from `text`, so the caller can recover its byte
359/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
360/// place while [`scan`] only needs the masked excerpt.
361///
362/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
363/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
364/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
365/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
366/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
367#[cfg(test)]
368fn scan_match(text: &str) -> Option<(&str, &'static str)> {
369    let context = EntropyScanContext::new(text);
370    scan_from(text, 0, &context)
371}
372
373/// Like [`scan_match`], but only returns secrets whose span starts at or after
374/// `from`, while still evaluating Layer-2 trigger context against the FULL
375/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
376/// entropy token is detected even when its only trigger word sits to the left of
377/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
378/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
379/// pointer arithmetic against the original `text` base stay absolute. The
380/// pre-tokenized entropy view is shared by all passes.
381fn scan_from<'a>(
382    text: &'a str,
383    from: usize,
384    context: &EntropyScanContext<'a>,
385) -> Option<(&'a str, &'static str)> {
386    scan_from_with_trigger(text, from, context).map(|(slice, detector, _)| (slice, detector))
387}
388
389fn scan_from_with_trigger<'a>(
390    text: &'a str,
391    from: usize,
392    context: &EntropyScanContext<'a>,
393) -> Option<(&'a str, &'static str, Option<&'static str>)> {
394    let mut best =
395        check_known_patterns(&text[from..]).map(|(slice, detector)| (slice, detector, None));
396    if let Some(candidate) = check_entropy_heuristic(text, from, context) {
397        if best
398            .as_ref()
399            .is_none_or(|current| candidate.0.as_ptr() < current.0.as_ptr())
400        {
401            best = Some(candidate);
402        }
403    }
404    best
405}
406
407/// Replace `best` with `cand` when `cand` starts earlier in the original text
408/// (`base` is the start address of that text). On a tie the incumbent wins, so
409/// callers offer more-specific detectors first. This is what makes
410/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
411/// rather than the first detector that happens to match anywhere.
412fn keep_leftmost<'a>(
413    best: &mut Option<(&'a str, &'static str)>,
414    cand: Option<(&'a str, &'static str)>,
415    base: usize,
416) {
417    if let Some((slice, name)) = cand {
418        let start = slice.as_ptr() as usize - base;
419        let replace = match *best {
420            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
421            None => true,
422        };
423        if replace {
424            *best = Some((slice, name));
425        }
426    }
427}
428
429/// Return the first `SecretMatch` found in `text`, or `None`.
430fn scan(text: &str) -> Option<SecretMatch> {
431    let context = EntropyScanContext::new(text);
432    scan_from_with_trigger(text, 0, &context).map(|(slice, detector, trigger)| {
433        let mut matched = build_match(detector, slice);
434        matched.trigger = trigger;
435        matched
436    })
437}
438
439/// A named redact-not-block surface whose contract is intentionally separate
440/// from manifest-backed write admission (ADR-115 Amendment 2).
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum RedactionSurface {
443    /// Git ingestion stores only masked commit, issue, and pull-request text.
444    GitIngest,
445    /// Session mirroring stores only masked `text` and `raw` projections.
446    SessionMirror,
447    /// MCP diagnostics are bounded caller-visible transport data, not records.
448    McpDiagnostic,
449    /// The kg `scan` verb's masked preview: caller-visible, never stored.
450    GateProbe,
451}
452
453/// Whether a redaction surface can consume a secret-gate exemption.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub enum RedactionSurfaceMode {
456    /// Always apply the canonical masker; never synthesize a stamp or event.
457    PermanentMaskOnly,
458}
459
460/// Machine-readable contract for a named redaction surface.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub struct RedactionSurfaceContract {
463    pub mode: RedactionSurfaceMode,
464    /// Durable target containing the masked result, if this is a write surface.
465    pub final_stored_target: Option<&'static str>,
466    /// Reserved stamp location; absent for permanent mask-only surfaces.
467    pub stamp_property: Option<&'static str>,
468    /// Atomic exemption-success event; absent when admission cannot occur.
469    pub atomic_success_event: Option<&'static str>,
470}
471
472/// Final stored target for [`RedactionSurface::GitIngest`] — see
473/// [`redaction_surface_contract`].
474pub const GIT_INGEST_STORED_TARGET: &str = "final git-ingest entity/note fields";
475
476/// Final stored target for [`RedactionSurface::SessionMirror`] — see
477/// [`redaction_surface_contract`]. Names every column the session mirror
478/// writes a masked provider-export projection into, not just the message
479/// body columns. `cwd`/`git_branch` live only on `sessions`, keyed per
480/// session — `session_messages` carries no such columns of its own (see the
481/// `session_messages` DDL in `khive-pack-session`).
482pub const SESSION_MIRROR_STORED_TARGET: &str =
483    "session_messages.text, session_messages.raw, sessions.cwd, sessions.git_branch, \
484     and sessions.slug";
485
486/// Return the closed contract for a named redact-not-block surface.
487pub const fn redaction_surface_contract(surface: RedactionSurface) -> RedactionSurfaceContract {
488    let final_stored_target = match surface {
489        RedactionSurface::GitIngest => Some(GIT_INGEST_STORED_TARGET),
490        RedactionSurface::SessionMirror => Some(SESSION_MIRROR_STORED_TARGET),
491        RedactionSurface::McpDiagnostic | RedactionSurface::GateProbe => None,
492    };
493
494    RedactionSurfaceContract {
495        mode: RedactionSurfaceMode::PermanentMaskOnly,
496        final_stored_target,
497        stamp_property: None,
498        atomic_success_event: None,
499    }
500}
501
502/// Apply the canonical detector to a named permanent mask-only surface.
503///
504/// This wrapper makes the non-admission decision executable at each call site:
505/// it has no manifest input, cannot return an exemption outcome, and cannot
506/// synthesize the runtime-owned `khive:secret_gate` property or success event.
507pub fn mask_for_redaction_surface(
508    surface: RedactionSurface,
509    text: &str,
510) -> std::borrow::Cow<'_, str> {
511    match redaction_surface_contract(surface).mode {
512        RedactionSurfaceMode::PermanentMaskOnly => mask_secrets(text),
513    }
514}
515
516#[cfg(test)]
517#[path = "secret_gate/issue_2655_tests.rs"]
518mod issue_2655_tests;
519
520#[cfg(test)]
521#[path = "secret_gate/note_fields_tests.rs"]
522mod note_fields_tests;
523
524// ─── Tests ───────────────────────────────────────────────────────────────────
525
526#[cfg(test)]
527#[path = "secret_gate_tests.rs"]
528mod tests;
529
530// ─── Corpus replay harness (manual, opt-in) ─────────────────────────────────
531//
532// Measures how many real note/entity strings the gate blocks, so a detector
533// change can be evaluated against production content rather than intuition
534// (see the module doc). Opens the target database
535// STRICTLY read-only (`SQLITE_OPEN_READ_ONLY`) and never mutates it. Point
536// `KHIVE_REPLAY_DB` at a copy or a live KG database file path; the harness
537// never writes, locks aggressively, or deletes anything.
538//
539// Run with: `KHIVE_REPLAY_DB=/path/to/khive.db cargo test -p khive-runtime \
540//   --release -- --ignored --nocapture corpus_replay`
541#[cfg(test)]
542#[path = "secret_gate/corpus_replay_tests.rs"]
543mod corpus_replay;