use std::time::SystemTime;
use sha2::{Digest, Sha256};
use ulid::Ulid;
use crate::auth::{AuthError, AuthHook, Principal};
use crate::ctx::{SessionHandle, ToolCtx, ToolResources};
use crate::manifest::{
BeginCallContext, CallHandle, FailureReason, ManifestError, ManifestStore, SnapshotRef,
};
use crate::registry::ToolRegistry;
use crate::session_id::{SessionIdError, SessionIdPolicy};
use crate::tool::{ResponseRedaction, ToolError, ToolResponse, ToolTier};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DispatchError {
#[error("session id rejected: {0}")]
SessionId(#[from] SessionIdError),
#[error("authorization rejected: {0}")]
Auth(#[from] AuthError),
#[error("unknown tool: {0}")]
UnknownTool(String),
#[error("manifest persistence failure: {0}")]
Manifest(#[from] ManifestError),
#[error("tool returned error: {0}")]
ToolError(#[from] ToolError),
#[error("redaction failed: {0}")]
Redaction(String),
#[error("response serialization failure: {0}")]
ResponseSerialization(#[source] serde_json::Error),
}
#[non_exhaustive]
pub struct PiiEnvelope<'a> {
pub registry: &'a ToolRegistry,
pub auth: &'a dyn AuthHook,
pub manifest: &'a dyn ManifestStore,
pub pipeline: &'a gaze::Pipeline,
pub session: &'a gaze::Session,
pub locale_chain: &'a [gaze::LocaleTag],
pub session_id_policy: &'a SessionIdPolicy,
}
impl<'a> PiiEnvelope<'a> {
pub fn new(
registry: &'a ToolRegistry,
auth: &'a dyn AuthHook,
manifest: &'a dyn ManifestStore,
pipeline: &'a gaze::Pipeline,
session: &'a gaze::Session,
locale_chain: &'a [gaze::LocaleTag],
session_id_policy: &'a SessionIdPolicy,
) -> Self {
Self {
registry,
auth,
manifest,
pipeline,
session,
locale_chain,
session_id_policy,
}
}
pub async fn dispatch(
&self,
principal: &Principal,
tool_name: &str,
raw_args: serde_json::Value,
external_session_id: Option<&str>,
) -> Result<ToolResponse, DispatchError> {
if let Some(sid) = external_session_id {
self.session_id_policy.validate(sid)?;
}
let tool = self
.registry
.get(tool_name)
.ok_or_else(|| DispatchError::UnknownTool(tool_name.to_string()))?;
let descriptor = tool.descriptor();
let tier = descriptor.tier();
match tier {
ToolTier::Agent => self.auth.authorize_agent(principal, tool_name).await?,
ToolTier::Operator => self.auth.authorize_operator(principal, tool_name).await?,
};
let redacted_args = redact_json(self.pipeline, self.session, &raw_args)
.map_err(|e| DispatchError::Redaction(e.to_string()))?;
let call_id = Ulid::new();
let started_at = SystemTime::now();
let begin_ctx = BeginCallContext {
call_id,
external_session_id,
principal_id: principal.id.as_str(),
tool_name,
redacted_args: &redacted_args,
started_at,
};
let handle = self.manifest.begin_call(begin_ctx).await?;
let audit_session_id_owned = match external_session_id {
Some(sid) => sid.to_string(),
None => call_id.to_string(),
};
let session_handle = SessionHandle::new(&audit_session_id_owned);
let resources = ToolResources::new(
self.pipeline,
self.session,
self.manifest,
self.locale_chain,
);
let ctx = ToolCtx::new_with_resources(
session_handle,
resources,
redacted_args.clone(),
call_id,
tool_name,
principal.id.as_str(),
);
let raw_response = match tool.invoke(&ctx).await {
Ok(resp) => resp,
Err(tool_err) => {
let reason = FailureReason::ToolError {
class: tool_err.class().to_string(),
message: tool_err.to_string(),
};
self.manifest.fail_call(handle, reason).await?;
return Err(DispatchError::ToolError(tool_err));
}
};
let response_payload = match (tier, descriptor.response_redaction()) {
(ToolTier::Agent, ResponseRedaction::BypassByOperator) => {
let reason = FailureReason::Other {
message: "agent tool with BypassByOperator reached dispatch".to_string(),
};
self.manifest.fail_call(handle, reason).await?;
return Err(DispatchError::Redaction(
"agent tool cannot bypass response redaction".to_string(),
));
}
(_, ResponseRedaction::Apply) => {
match redact_json(self.pipeline, self.session, &raw_response.payload) {
Ok(value) => value,
Err(e) => {
let reason = FailureReason::RedactionFailed {
message: e.to_string(),
};
self.manifest.fail_call(handle, reason).await?;
return Err(DispatchError::Redaction(e.to_string()));
}
}
}
(ToolTier::Operator, ResponseRedaction::BypassByOperator) => raw_response.payload,
};
let snapshot = match build_snapshot_ref(&audit_session_id_owned, call_id, &response_payload)
{
Ok(snap) => snap,
Err(e) => {
let reason = FailureReason::Other {
message: format!("response serialization failure: {e}"),
};
self.manifest.fail_call(handle, reason).await?;
return Err(DispatchError::ResponseSerialization(e));
}
};
self.manifest.finish_call(handle, snapshot).await?;
Ok(ToolResponse::json(response_payload))
}
}
fn redact_json(
pipeline: &gaze::Pipeline,
session: &gaze::Session,
value: &serde_json::Value,
) -> Result<serde_json::Value, gaze::Error> {
use serde_json::Value as JsonValue;
match value {
JsonValue::String(s) => Ok(JsonValue::String(redact_json_string(pipeline, session, s)?)),
JsonValue::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for item in arr {
out.push(redact_json(pipeline, session, item)?);
}
Ok(JsonValue::Array(out))
}
JsonValue::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
out.insert(k.clone(), redact_json(pipeline, session, v)?);
}
Ok(JsonValue::Object(out))
}
other => Ok(other.clone()),
}
}
fn redact_json_string(
pipeline: &gaze::Pipeline,
session: &gaze::Session,
value: &str,
) -> Result<String, gaze::Error> {
let mut out = String::with_capacity(value.len());
let mut cursor = 0usize;
for token in gaze::token_shape::pattern().find_iter(value) {
if !session.contains_token(token.as_str()) {
continue;
}
out.push_str(&redact_json_string_segment(
pipeline,
session,
&value[cursor..token.start()],
)?);
out.push_str(token.as_str());
cursor = token.end();
}
out.push_str(&redact_json_string_segment(
pipeline,
session,
&value[cursor..],
)?);
Ok(out)
}
fn redact_json_string_segment(
pipeline: &gaze::Pipeline,
session: &gaze::Session,
segment: &str,
) -> Result<String, gaze::Error> {
if segment.is_empty() {
return Ok(String::new());
}
let clean = pipeline.redact(session, gaze::RawDocument::Text(segment.to_string()))?;
match clean {
gaze::CleanDocument::Text(text) => Ok(text),
_ => Err(gaze::Error::UnsupportedRawDocumentVariant),
}
}
fn build_snapshot_ref(
audit_session_id: &str,
call_id: Ulid,
payload: &serde_json::Value,
) -> Result<SnapshotRef, serde_json::Error> {
let bytes = serde_json::to_vec(payload)?;
let mut hasher = Sha256::new();
hasher.update(audit_session_id.as_bytes());
hasher.update([0u8]);
hasher.update(call_id.to_bytes());
hasher.update([0u8]);
hasher.update(&bytes);
let digest = hasher.finalize();
let sha256_hex = hex_lower(&digest);
let locator = format!("inline-sha256:{call_id}");
Ok(SnapshotRef::new(locator, sha256_hex, bytes.len() as u64))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[doc(hidden)]
pub fn _debug_call_handle(handle: CallHandle) -> Ulid {
handle.id()
}
#[cfg(test)]
mod tests {
use super::*;
use gaze::{Action, ClassRule, DefaultRule, Detection, Detector, PiiClass, RawDocument};
use serde_json::json;
#[derive(Clone)]
struct FixedDetector;
impl Detector for FixedDetector {
fn detect(&self, input: &str) -> Vec<Detection> {
input
.find("alice@example.invalid")
.map(|start| {
Detection::new(
start..start + "alice@example.invalid".len(),
PiiClass::Email,
"fixed",
)
})
.into_iter()
.collect()
}
}
fn tokenizing_pipeline() -> gaze::Pipeline {
gaze::Pipeline::builder()
.detector(FixedDetector)
.rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
.rule(DefaultRule::new(Action::Preserve))
.build()
.expect("pipeline")
}
#[test]
fn snapshot_ref_preimage_byte_sequence_exact() {
let call_id = Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").expect("ulid");
let payload = json!({"email": "alice@example.invalid"});
let snapshot =
build_snapshot_ref("audit-session", call_id, &payload).expect("snapshot ref");
let payload_bytes = serde_json::to_vec(&payload).expect("payload bytes");
let mut hasher = Sha256::new();
hasher.update(b"audit-session");
hasher.update([0u8]);
hasher.update(call_id.to_bytes());
hasher.update([0u8]);
hasher.update(&payload_bytes);
let expected = hex_lower(&hasher.finalize());
assert_eq!(snapshot.sha256_hex, expected);
assert_eq!(snapshot.byte_len, payload_bytes.len() as u64);
}
#[test]
fn redact_json_preserves_session_owned_token_shapes() {
let pipeline = tokenizing_pipeline();
let session = gaze::Session::new(gaze::Scope::Ephemeral).expect("session");
let tokenized = pipeline
.redact(
&session,
RawDocument::Text("alice@example.invalid".to_string()),
)
.expect("redact");
let token = match tokenized {
gaze::CleanDocument::Text(text) => text,
_ => panic!("expected text"),
};
let payload = json!(format!("{token}alice@example.invalid"));
let redacted = redact_json(&pipeline, &session, &payload).expect("redact json");
assert_eq!(redacted, json!(format!("{token}{token}")));
}
}