Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tinysecurity-approval/src/automatic_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ impl tinysecurity_auto::Clock for TestClock {
}
fn judge_config() -> JudgeConfig {
JudgeConfig {
credential: Secret("fake-judge-key".into()),
credential: Some(Secret("fake-judge-key".into())),
endpoint: "http://127.0.0.1:1/jev".into(),
provider: JudgeProvider::TypeSafe,
threshold_millis: 900,
Expand Down
2 changes: 1 addition & 1 deletion crates/tinysecurity-approval/src/judge_matrix_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ impl tinysecurity_auto::JudgeClient for Stub {
}
fn config(origins: Vec<Origin>, max_requests: u32) -> JudgeConfig {
JudgeConfig {
credential: Secret("fake-judge-key".into()),
credential: Some(Secret("fake-judge-key".into())),
endpoint: "http://127.0.0.1:1/jev".into(),
provider: JudgeProvider::TypeSafe,
threshold_millis: 900,
Expand Down
10 changes: 10 additions & 0 deletions crates/tinysecurity-auto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ lookup or per-call credential is supported. Invalid init/reinit rejects without
changing the active policy. The credential and init endpoint are registered as
redaction literals. Configuration, clients and prepared inputs have redacted Debug.

A host that can only read the credential through the admitted module (from its
keyring) leaves `credential` out. The module then asks the init-bound trusted host
through the confidential `JudgeCredentialHost2.Credential` callback on the first
judgement, never during load, within the judge's `timeout_ms`. It reuses the client
for five minutes (`HOST_CREDENTIAL_TTL_MS`). An endpoint rejection (401/403) drops
it at once, and the next ask says `rejected`. A reinit drops it too. No credential,
or a failed ask, sends that request to a person, the same as an unavailable judge.
A credential supplied this way arrives after init, so it is not registered as a
redaction literal; it is used only in the outbound request header.

`threshold_millis` is the default inclusive minimum for both intent and
reversibility. `exfiltrate_millis` is the inclusive maximum for exfiltration.
`thresholds` can override these three bounds by origin and verified local/external
Expand Down
3 changes: 3 additions & 0 deletions crates/tinysecurity-auto/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub enum Error {
/// Transport or provider unavailable.
#[error("judge unavailable")]
Unavailable,
/// The endpoint rejected the credential.
#[error("judge credential rejected")]
Rejected,
}
/// Native judge result with a sanitized local error.
pub type Result<T> = std::result::Result<T, Error>;
107 changes: 107 additions & 0 deletions crates/tinysecurity-auto/src/host.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! Judge credential asked of the host when a judgement first needs it.
//!
//! A host that keeps the credential somewhere only the admitted module can read
//! (its keyring) cannot supply it at load. [`HostClient`] asks a
//! [`CredentialSource`] on the first evaluation instead, reuses the client for
//! [`HOST_CREDENTIAL_TTL_MS`], and drops it as soon as the endpoint rejects the
//! credential, so rotation and sign-out are picked up.
use crate::{Clock, Error, JudgeClient, Result, SdkClient};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tinyinference_decisions::{EvaluationRequest, EvaluationResponse};
use tinysecurity_bus::{JudgeConfig, Secret};

/// How long a host-supplied credential is reused before the host is asked again.
pub const HOST_CREDENTIAL_TTL_MS: u64 = 300_000;

/// The trusted host's current judge credential.
#[async_trait::async_trait]
pub trait CredentialSource: Send + Sync + std::fmt::Debug {
/// `rejected` says the endpoint refused the last credential. `None`, or any
/// failure, sends this judgement to a person.
async fn credential(&self, rejected: bool) -> Option<Secret>;
}

/// Builds the outbound client for one credential.
pub(crate) type Build =
Arc<dyn Fn(&JudgeConfig, &Secret) -> Result<Arc<dyn JudgeClient>> + Send + Sync>;

/// The production builder: one SDK client per credential.
pub(crate) fn sdk() -> Build {
Arc::new(|config, credential| Ok(Arc::new(SdkClient::with_credential(config, credential)?)))
}

/// A [`JudgeClient`] whose credential comes from the host on demand.
pub(crate) struct HostClient {
config: JudgeConfig,
source: Arc<dyn CredentialSource>,
clock: Arc<dyn Clock>,
build: Build,
// Held across the host callback only, so one ask runs at a time; never
// across the outbound evaluation.
held: tokio::sync::Mutex<Option<(Arc<dyn JudgeClient>, u64)>>,
rejected: AtomicBool,
}
impl std::fmt::Debug for HostClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("HostClient([REDACTED])")
}
}
impl HostClient {
pub(crate) fn new(
config: JudgeConfig,
source: Arc<dyn CredentialSource>,
clock: Arc<dyn Clock>,
build: Build,
) -> Self {
Self {
config,
source,
clock,
build,
held: tokio::sync::Mutex::new(None),
rejected: AtomicBool::new(false),
}
}
async fn client(&self) -> Result<Arc<dyn JudgeClient>> {
let mut held = self.held.lock().await;
let now = self.clock.milliseconds();
if let Some((client, since)) = held.as_ref()
&& now.saturating_sub(*since) < HOST_CREDENTIAL_TTL_MS
{
return Ok(Arc::clone(client));
}
*held = None;
let credential = self
.source
.credential(self.rejected.load(Ordering::SeqCst))
.await
.filter(|secret| (4..=4096).contains(&secret.0.len()))
.ok_or(Error::Unavailable)?;
let client = (self.build)(&self.config, &credential)?;
self.rejected.store(false, Ordering::SeqCst);
*held = Some((Arc::clone(&client), now));
Ok(client)
}
}
#[async_trait::async_trait]
impl JudgeClient for HostClient {
async fn evaluate(&self, request: &EvaluationRequest) -> Result<EvaluationResponse> {
let client = self.client().await?;
let result = client.evaluate(request).await;
if matches!(result, Err(Error::Rejected)) {
// Drop exactly the client that failed; a fresh one may already be held.
let mut held = self.held.lock().await;
if held
.as_ref()
.is_some_and(|(current, _)| Arc::ptr_eq(current, &client))
{
*held = None;
}
self.rejected.store(true, Ordering::SeqCst);
}
result
}
}
90 changes: 80 additions & 10 deletions crates/tinysecurity-auto/src/judge.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
//! Redaction-first Jev requests, exact score binding and total deadlines.
use crate::{Clock, Error, Result, budget::Budget, cache::Cache};
use crate::{
Clock, Error, Result,
budget::Budget,
cache::Cache,
host::{self, CredentialSource, HostClient},
};
use sha2::{Digest, Sha256};
use std::{
sync::{Arc, Mutex},
Expand All @@ -10,7 +15,7 @@ use tinyinference_decisions::{
};
use tinysecurity_bus::{
ApprovalBinding2, JudgeConfig, JudgeEvidence2, JudgeOutcome2, JudgeProvider, JudgeThresholds,
RedactMode, RedactRequest,
RedactMode, RedactRequest, Secret,
};
use tinysecurity_redact::Redactor;
const QUESTIONS: [&str; 3] = ["intent", "reversible", "exfiltrate"];
Expand Down Expand Up @@ -55,10 +60,16 @@ impl SdkClient {
/// # Errors
/// Rejects invalid credentials, endpoints or timeout settings.
pub fn new(config: &JudgeConfig) -> Result<Self> {
Self::with_credential(config, config.credential.as_ref().ok_or(Error::Invalid)?)
}
/// The same client for a credential the host supplied after load.
/// # Errors
/// Rejects invalid credentials, endpoints or timeout settings.
pub fn with_credential(config: &JudgeConfig, credential: &Secret) -> Result<Self> {
let mut client = match config.provider {
JudgeProvider::TypeSafe => ClientConfig::new(config.credential.0.clone()),
JudgeProvider::OpenRouter => ClientConfig::openrouter(config.credential.0.clone()),
JudgeProvider::OpenJev => ClientConfig::openjev(config.credential.0.clone()),
JudgeProvider::TypeSafe => ClientConfig::new(credential.0.clone()),
JudgeProvider::OpenRouter => ClientConfig::openrouter(credential.0.clone()),
JudgeProvider::OpenJev => ClientConfig::openjev(credential.0.clone()),
}
.with_endpoint_url(&config.endpoint);
client.timeout = Duration::from_millis(u64::from(config.timeout_ms));
Expand All @@ -73,7 +84,18 @@ impl JudgeClient for SdkClient {
.evaluate(request)
.await
.map(|r| r.response)
.map_err(|_| Error::Unavailable)
.map_err(|error| classify(&error))
}
}
/// A rejected credential (401/403) is told apart; every other failure is
/// `Unavailable`. The client wraps each failed evaluation with its attempt
/// count, so the classified cause is inside.
fn classify(error: &tinyinference_decisions::Error) -> Error {
use tinyinference_decisions::Error as Sdk;
match error {
Sdk::EvaluationFailure(failure) => classify(&failure.error),
Sdk::Authentication => Error::Rejected,
_ => Error::Unavailable,
}
}
/// Shared bounded native scorer; locks protect reservations only, never HTTP waits.
Expand All @@ -83,6 +105,7 @@ pub struct Engine {
client: Arc<dyn JudgeClient>,
clock: Arc<dyn Clock>,
state: Arc<Mutex<(Budget, Cache)>>,
source: Option<Arc<dyn CredentialSource>>,
}
impl std::fmt::Debug for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Expand All @@ -97,6 +120,39 @@ impl Engine {
let client = Arc::new(SdkClient::new(&config)?);
Self::with_client(config, client, clock)
}
/// Construct a production scorer that, when `config` carries no
/// credential, asks `source` for it on the first judgement and reuses it
/// for [`host::HOST_CREDENTIAL_TTL_MS`]. A configured credential is used
/// as by [`Engine::new`].
/// # Errors
/// Rejects invalid bounded settings and endpoint/key configuration.
pub fn with_credential_source(
config: JudgeConfig,
source: Arc<dyn CredentialSource>,
clock: Arc<dyn Clock>,
) -> Result<Self> {
Self::with_source(config, Some(source), clock, host::sdk())
}
fn with_source(
config: JudgeConfig,
source: Option<Arc<dyn CredentialSource>>,
clock: Arc<dyn Clock>,
build: host::Build,
) -> Result<Self> {
let client: Arc<dyn JudgeClient> = match (&config.credential, &source) {
(Some(credential), _) => build(&config, credential)?,
(None, Some(source)) => Arc::new(HostClient::new(
config.clone(),
Arc::clone(source),
Arc::clone(&clock),
build,
)),
(None, None) => return Err(Error::Invalid),
};
let mut engine = Self::with_client(config, client, clock)?;
engine.source = source;
Ok(engine)
}
/// Inject a one-attempt typed evaluator and monotonic clock.
/// # Errors
/// Rejects invalid limits, duplicate origins/rules and local score bounds.
Expand All @@ -117,20 +173,32 @@ impl Engine {
client,
clock,
state,
source: None,
})
}
/// Validate the complete explicit initialization before activation.
/// # Errors
/// Rejects invalid local limits or SDK endpoint/credential settings.
pub fn validate_config(config: &JudgeConfig) -> Result<()> {
validate(config)?;
SdkClient::new(config).map(|_| ())
match &config.credential {
Some(credential) => SdkClient::with_credential(config, credential).map(|_| ()),
// The endpoint and timeout are checked now; the placeholder key is
// never sent, because this client is dropped unused.
None => SdkClient::with_credential(config, &Secret("unset".into())).map(|_| ()),
}
}
/// Prepare a new configuration while preserving the shared reservation ledger.
/// # Errors
/// Rejects invalid settings without mutating the current engine.
pub fn reconfigured(&self, config: JudgeConfig) -> Result<Self> {
let mut candidate = Self::new(config, Arc::clone(&self.clock))?;
// A fresh client: a host credential held before the reinit is dropped.
let mut candidate = Self::with_source(
config,
self.source.clone(),
Arc::clone(&self.clock),
host::sdk(),
)?;
candidate.state = Arc::clone(&self.state);
candidate
.state
Expand Down Expand Up @@ -285,8 +353,10 @@ fn validate(config: &JudgeConfig) -> Result<()> {
|| config.cache_ttl_ms > 600_000
|| config.origins.len() > 6
|| config.thresholds.len() > 12
|| config.credential.0.len() < 4
|| config.credential.0.len() > 4096
|| config
.credential
.as_ref()
.is_some_and(|credential| !(4..=4096).contains(&credential.0.len()))
|| config.endpoint.len() > 4096
|| config
.origins
Expand Down
Loading
Loading