use std::path::Path;
use anyhow::Result;
use zeroize::Zeroizing;
use crate::core::judge::{self, JudgeContext, JudgeRuntime, JudgeVerdict};
use crate::core::policy::{
self, Decision, Seal, Tier, VaultPolicyData, SEAL_DENY_THRESHOLD, SEAL_WINDOW_SECS,
};
use crate::core::vault::Vault;
use crate::core::{audit, keyring, usage};
const HIGH_HUMAN_ONLY: &str =
"high-sensitivity secret — a human must retrieve it via 'svault secret get'";
pub const GENERIC_DENY: &str = "request not authorized for this secret";
pub struct Verdict {
pub decision: Decision,
pub note: String,
}
impl Verdict {
pub fn allowed(&self) -> bool {
matches!(self.decision, Decision::Allow(_))
}
pub fn tier(&self) -> Tier {
self.decision.tier()
}
}
pub fn authorize(
policy: &VaultPolicyData,
req: &policy::Request,
judge: Option<&JudgeRuntime>,
) -> Verdict {
if let Some(seal) = policy.seals.get(req.secret) {
let tier = policy
.classify(req.secret)
.map(|r| r.tier)
.unwrap_or_default();
return Verdict {
decision: Decision::Deny(tier, format!("sealed: {}", seal.trigger)),
note: format!("sealed at {} ({})", seal.sealed_at, seal.trigger),
};
}
let base = policy::evaluate(policy, req);
let tier = base.tier();
if let Decision::Deny(_, why) = &base {
let note = why.clone();
return Verdict {
decision: base,
note,
};
}
let vault_enabled = policy.judge.enabled.unwrap_or(true);
let active = judge.is_some() && vault_enabled;
let rule = policy.classify(req.secret);
let require_reason = rule.map(|r| r.require_reason).unwrap_or(false);
let consult = active && (tier != Tier::Low || require_reason);
if !consult {
return match tier {
Tier::Low => allow(tier, "ok"),
Tier::Medium => allow(tier, "elevated (judge off)"),
Tier::High if !active => deny(tier, HIGH_HUMAN_ONLY),
Tier::High => allow(tier, "ok"),
};
}
let rt = judge.expect("active implies Some");
let model = rt.model.clone();
let recent = recent_summary(req.vault_dir, req.caller);
let ctx = JudgeContext {
caller: req.caller,
scope: req.scope,
reason: req.reason,
secret: req.secret,
tier,
vault: req.vault,
vault_description: req.vault_description,
secret_description: rule.map(|r| r.description.as_str()).unwrap_or(""),
recent: &recent,
};
let verdict = judge::evaluate(rt, &model, &ctx);
let threshold = if tier == Tier::High {
rt.high_threshold
} else {
rt.allow_threshold
};
let fail_open = tier != Tier::High;
match verdict {
JudgeVerdict::Allow { score, rationale } if score >= threshold => Verdict {
decision: Decision::Allow(tier),
note: format!("judge allow ({score}): {rationale}"),
},
JudgeVerdict::Allow { score, rationale } | JudgeVerdict::Deny { score, rationale } => {
Verdict {
decision: Decision::Deny(tier, format!("judge denied (score {score})")),
note: format!("judge deny ({score}): {rationale}"),
}
}
JudgeVerdict::Unavailable { err } => {
if fail_open {
Verdict {
decision: Decision::Allow(tier),
note: format!("judge-unavailable (fail-open): {err}"),
}
} else {
Verdict {
decision: Decision::Deny(
tier,
"AI judge unavailable — high-tier access fails closed".to_string(),
),
note: format!("judge-unavailable (fail-closed): {err}"),
}
}
}
}
}
pub enum GatedGet {
Granted {
value: Zeroizing<String>,
tier: Tier,
},
Denied,
NotFound,
}
pub fn gated_get(
vault: &Vault,
vault_dir: &Path,
caller: &str,
secret: &str,
scope: &str,
reason: &str,
) -> Result<GatedGet> {
let judge = keyring::open_from_session().and_then(|kr| {
kr.data
.resolve_judge(vault.policy.judge.judge.as_deref())
.and_then(|(_n, def)| JudgeRuntime::from_def(def))
});
let req = policy::Request {
vault: &vault.meta.name,
vault_description: &vault.meta.description,
vault_dir,
secret,
scope,
reason,
caller,
};
let verdict = authorize(&vault.policy, &req, judge.as_ref());
let decision_str = if verdict.allowed() { "allow" } else { "deny" };
audit::record(
vault_dir,
&audit::Entry::now(
caller,
secret,
scope,
&verdict.tier().to_string(),
decision_str,
&verdict.note,
reason,
),
)?;
usage::agent(
vault_dir,
caller,
&format!("get.{decision_str}"),
Some(secret),
);
if !verdict.allowed() {
maybe_seal(vault, vault_dir, secret, verdict.tier(), caller);
return Ok(GatedGet::Denied);
}
match vault.get_secret(secret)? {
Some(value) => Ok(GatedGet::Granted {
value,
tier: verdict.tier(),
}),
None => Ok(GatedGet::NotFound),
}
}
pub fn maybe_seal(vault: &Vault, vault_dir: &Path, secret: &str, tier: Tier, caller: &str) -> bool {
if tier == Tier::Low || vault.policy.seals.contains_key(secret) {
return false;
}
let since = chrono::Utc::now() - chrono::Duration::seconds(SEAL_WINDOW_SECS);
let denials = audit::recent_for_secret(vault_dir, secret, since)
.map(|es| es.iter().filter(|e| e.decision == "deny").count())
.unwrap_or(0);
if denials < SEAL_DENY_THRESHOLD {
return false;
}
let mut policy = vault.policy.clone();
policy.seals.insert(
secret.to_string(),
Seal {
sealed_at: chrono::Utc::now().to_rfc3339(),
trigger: format!("{denials} denials in {SEAL_WINDOW_SECS}s"),
last_caller: caller.to_string(),
denials: denials as u32,
},
);
if vault.save_policy(&policy).is_ok() {
usage::agent(vault_dir, caller, "secret.sealed", Some(secret));
true
} else {
false
}
}
fn allow(tier: Tier, note: &str) -> Verdict {
Verdict {
decision: Decision::Allow(tier),
note: note.to_string(),
}
}
fn deny(tier: Tier, why: &str) -> Verdict {
Verdict {
decision: Decision::Deny(tier, why.to_string()),
note: why.to_string(),
}
}
pub fn recent_summary(vault_dir: &Path, caller: &str) -> String {
let since = chrono::Utc::now() - chrono::Duration::hours(1);
let entries = crate::core::audit::recent(vault_dir, caller, since).unwrap_or_default();
if entries.is_empty() {
return "no prior requests in the last hour".to_string();
}
let allowed = entries.iter().filter(|e| e.decision == "allow").count();
let denied = entries.len() - allowed;
format!(
"{} request(s) in the last hour ({allowed} allowed, {denied} denied)",
entries.len()
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::meta::{VaultMeta, VaultSettings};
use crate::core::policy::{SecretRule, VaultPolicyData};
use tempfile::TempDir;
fn vault_with_secret(dir: &TempDir, tier: Tier) -> (Vault, std::path::PathBuf) {
let vault_dir = dir.path().join("v");
let mut policy = VaultPolicyData::default();
policy.secrets.insert(
"API_KEY".into(),
SecretRule {
scope: "api".into(),
tier,
..Default::default()
},
);
let meta = VaultMeta::new("v".into(), "d".into(), VaultSettings::default());
let v = Vault::init(&vault_dir, "Str0ng!Pass#99", meta, policy).unwrap();
v.add_secret("API_KEY", "s3cr3t").unwrap();
(v, vault_dir)
}
fn seed_denials(vault_dir: &Path, secret: &str, n: usize) {
for _ in 0..n {
audit::record(
vault_dir,
&audit::Entry::now(
"attacker", secret, "api", "medium", "deny", "nope", "probe me",
),
)
.unwrap();
}
}
#[test]
fn repeated_denials_seal_a_medium_secret() {
let dir = TempDir::new().unwrap();
let (v, vault_dir) = vault_with_secret(&dir, Tier::Medium);
seed_denials(&vault_dir, "API_KEY", SEAL_DENY_THRESHOLD);
assert!(gate_maybe_seal(&v, &vault_dir));
let reopened = Vault::open(&vault_dir, "Str0ng!Pass#99").unwrap();
assert!(reopened.policy.seals.contains_key("API_KEY"));
assert!(!gate_maybe_seal(&reopened, &vault_dir));
}
#[test]
fn low_tier_never_seals() {
let dir = TempDir::new().unwrap();
let (v, vault_dir) = vault_with_secret(&dir, Tier::Low);
seed_denials(&vault_dir, "API_KEY", SEAL_DENY_THRESHOLD + 3);
assert!(!maybe_seal(
&v,
&vault_dir,
"API_KEY",
Tier::Low,
"attacker"
));
}
#[test]
fn under_threshold_does_not_seal() {
let dir = TempDir::new().unwrap();
let (v, vault_dir) = vault_with_secret(&dir, Tier::Medium);
seed_denials(&vault_dir, "API_KEY", SEAL_DENY_THRESHOLD - 1);
assert!(!gate_maybe_seal(&v, &vault_dir));
}
#[test]
fn sealed_secret_denies_an_otherwise_allowable_request() {
let dir = TempDir::new().unwrap();
let mut policy = VaultPolicyData::default();
policy.secrets.insert(
"API_KEY".into(),
SecretRule {
scope: "api".into(),
tier: Tier::Medium,
..Default::default()
},
);
let req = policy::Request {
vault: "v",
vault_description: "",
vault_dir: dir.path(),
secret: "API_KEY",
scope: "api",
reason: "legitimate use of the api key",
caller: "claude",
};
assert!(authorize(&policy, &req, None).allowed());
policy.seals.insert(
"API_KEY".into(),
Seal {
sealed_at: chrono::Utc::now().to_rfc3339(),
trigger: "5 denials in 300s".into(),
last_caller: "attacker".into(),
denials: 5,
},
);
assert!(!authorize(&policy, &req, None).allowed());
}
fn gate_maybe_seal(v: &Vault, vault_dir: &Path) -> bool {
maybe_seal(v, vault_dir, "API_KEY", Tier::Medium, "attacker")
}
}