Skip to content

Commit 03d5e36

Browse files
senamakelmedullabot
andcommitted
fix(approval): recheck reusable grants and shrink judge caches
Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 09207f1 commit 03d5e36

8 files changed

Lines changed: 216 additions & 42 deletions

File tree

‎crates/tinysecurity-approval/src/binding.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ pub(crate) fn original(
166166
}
167167
pub(crate) fn envelope(row: &ApprovalRow2) -> Result<ApprovalRecord2> {
168168
let json = serde_json::to_string(row).map_err(|_| Error::Invalid)?;
169-
if json.len() > 65536
169+
if row.created_at_ms >= row.expires_at_ms
170+
|| json.len() > 65536
170171
|| !hex(&row.binding.call_sha256, 64)
171172
|| !hex(&row.binding.content_sha256, 64)
172173
{

‎crates/tinysecurity-approval/src/engine.rs‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -193,25 +193,11 @@ impl Engine {
193193
}
194194
Self::correlation(&request.audit, reg.generation)?;
195195
let rows = self.rows(&reg).await?;
196-
// Durable identities replace retry identities once confirmed by exact
197-
// load. Retain every indeterminate creation not present in this snapshot.
198-
self.creations
199-
.lock()
200-
.map_err(|_| Error::Unavailable)?
201-
.retain(|_, pending| {
202-
!rows.iter().any(|row| {
203-
row.request_id == pending.request_id && row.binding == pending.binding
204-
})
205-
});
196+
self.reconcile_creations(&rows)?;
206197
if let Some(row) = rows.iter().find(|r| r.binding == bound) {
207198
return self.resume(&reg, row, now).await;
208199
}
209-
let reusable = reusable_grant(&rows, &bound, now);
210-
if let Some(source) = reusable {
211-
// A decision CAS can succeed before its required audit fails. Reuse
212-
// must reconcile that exact phase before a derived permission exists.
213-
self.ports.audit(source.clone()).await?;
214-
}
200+
let reusable = self.live_reusable(&reg, &bound, &rows, now).await?;
215201
let automatic = rule == tinysecurity_auto::rules::Rule::Automatic;
216202
let judge = if reusable.is_none() && !automatic {
217203
self.score(&request, &bound, external_effect).await?
@@ -226,6 +212,12 @@ impl Engine {
226212
return Err(Error::Denied);
227213
}
228214
let now = self.now()?;
215+
if reusable
216+
.as_ref()
217+
.is_some_and(|source| source.expires_at_ms <= now)
218+
{
219+
return Err(Error::Denied);
220+
}
229221
let approved = reusable.is_some() || automatic || qualified;
230222
let expiry = now.checked_add(ttl).ok_or(Error::Invalid)?;
231223
let row = ApprovalRow2 {
@@ -243,7 +235,9 @@ impl Engine {
243235
args_redacted,
244236
summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?,
245237
created_at_ms: now,
246-
expires_at_ms: reusable.map_or(expiry, |r| expiry.min(r.expires_at_ms)),
238+
expires_at_ms: reusable
239+
.as_ref()
240+
.map_or(expiry, |r| expiry.min(r.expires_at_ms)),
247241
state: if approved {
248242
ApprovalLifecycle2::Approved
249243
} else {
@@ -276,6 +270,40 @@ impl Engine {
276270
};
277271
self.finish_begin(&reg, &row, now).await
278272
}
273+
fn reconcile_creations(&self, rows: &[ApprovalRow2]) -> Result<()> {
274+
// Durable identities replace retry identities once confirmed by exact
275+
// load. Retain every indeterminate creation not present in this snapshot.
276+
self.creations
277+
.lock()
278+
.map_err(|_| Error::Unavailable)?
279+
.retain(|_, pending| {
280+
!rows.iter().any(|row| {
281+
row.request_id == pending.request_id && row.binding == pending.binding
282+
})
283+
});
284+
Ok(())
285+
}
286+
async fn live_reusable(
287+
&self,
288+
reg: &ApprovalScope2,
289+
bound: &tinysecurity_bus::ApprovalBinding2,
290+
rows: &[ApprovalRow2],
291+
now: u64,
292+
) -> Result<Option<ApprovalRow2>> {
293+
let Some(source) = reusable_grant(rows, bound, now) else {
294+
return Ok(None);
295+
};
296+
// Reconcile the exact source audit, then reload mutable state and time.
297+
// No callback lock is held, and an expired source never creates a row.
298+
self.ports.audit(source.clone()).await?;
299+
let current = self.rows(reg).await?;
300+
let now = self.now()?;
301+
let live = reusable_grant(&current, bound, now)
302+
.filter(|row| row.request_id == source.request_id)
303+
.cloned()
304+
.ok_or(Error::Denied)?;
305+
Ok(Some(live))
306+
}
279307
async fn score(
280308
&self,
281309
request: &EvaluateApprovalRequest2,

‎crates/tinysecurity-approval/src/engine_tests.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct Fixture {
3434
reentrant: Mutex<Option<std::sync::Weak<Engine>>>,
3535
swallow_decision_audit_failure: AtomicU64,
3636
audits: Mutex<Vec<ApprovalRow2>>,
37+
advance_source_audit: Mutex<Option<(Arc<TestClock>, u64)>>,
3738
audit_receipts: Mutex<std::collections::BTreeMap<(String, u64), ApprovalRow2>>,
3839
barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
3940
}
@@ -149,6 +150,15 @@ impl Ports for Fixture {
149150
})
150151
}
151152
async fn audit(&self, _r: ApprovalRow2) -> Result<()> {
153+
if _r.decision_ticket_sha256.is_some()
154+
&& let Some((clock, now)) = self
155+
.advance_source_audit
156+
.lock()
157+
.map_err(|_| Error::Unavailable)?
158+
.take()
159+
{
160+
clock.wall.store(now, Ordering::SeqCst);
161+
}
152162
self.order
153163
.lock()
154164
.map_err(|_| Error::Unavailable)?
@@ -1013,3 +1023,34 @@ async fn fresh_and_resumed_prompt_reconcile_a_lost_decision_audit_acknowledgemen
10131023

10141024
#[path = "automatic_tests.rs"]
10151025
mod automatic_tests;
1026+
1027+
#[tokio::test]
1028+
async fn source_expiring_during_audit_never_poison_durable_subject_rows() -> Result<()> {
1029+
for choice in [1, 2] {
1030+
let f = Arc::new(Fixture::default());
1031+
f.choice.store(choice, Ordering::SeqCst);
1032+
let clock = Arc::new(TestClock::default());
1033+
let owner = engine(f.clone(), clock.clone())?;
1034+
let mut input = request();
1035+
input.request.caller.flow = Some("reviewed-flow".into());
1036+
let pending = owner.begin(input.clone()).await?;
1037+
let source = owner.decide(decision(&pending)).await?;
1038+
*f.advance_source_audit
1039+
.lock()
1040+
.map_err(|_| Error::Unavailable)? = Some((clock, source.expires_at_ms));
1041+
input.request.caller.call_id = "expired-source-execution".into();
1042+
input.audit = audit(3);
1043+
assert!(owner.begin(input.clone()).await.is_err());
1044+
assert_eq!(
1045+
f.rows.lock().map_err(|_| Error::Unavailable)?.len(),
1046+
1,
1047+
"no invalid derived CAS"
1048+
);
1049+
input.request.caller.call_id = "future-independent-execution".into();
1050+
input.request.call.tool = "independent_tool".into();
1051+
let future = owner.begin(input).await?;
1052+
assert_eq!(future.state, ApprovalLifecycle2::Pending);
1053+
assert!(future.created_at_ms < future.expires_at_ms);
1054+
}
1055+
Ok(())
1056+
}

‎crates/tinysecurity-auto/src/cache.rs‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,36 @@ use std::collections::BTreeMap;
33
#[derive(Debug, Default)]
44
pub(crate) struct Cache(BTreeMap<String, (u64, [f64; 3])>);
55
impl Cache {
6+
pub(crate) fn resize(&mut self, bound: u16) {
7+
while self.0.len() > usize::from(bound) {
8+
let oldest = self
9+
.0
10+
.iter()
11+
.min_by_key(|(_, (expiry, _))| *expiry)
12+
.map(|(key, _)| key.clone());
13+
if let Some(oldest) = oldest {
14+
self.0.remove(&oldest);
15+
}
16+
}
17+
}
18+
#[cfg(test)]
19+
pub(crate) fn len(&self) -> usize {
20+
self.0.len()
21+
}
622
pub(crate) fn get(&mut self, key: &str, now: u64) -> Option<[f64; 3]> {
723
self.0.retain(|_, (expires, _)| *expires > now);
824
self.0.get(key).map(|(_, scores)| *scores)
925
}
1026
pub(crate) fn put(&mut self, key: String, now: u64, ttl: u32, bound: u16, scores: [f64; 3]) {
27+
self.resize(bound);
1128
if ttl == 0 || bound == 0 {
1229
return;
1330
}
1431
let Some(expires) = now.checked_add(u64::from(ttl)) else {
1532
return;
1633
};
1734
self.0.retain(|_, (expires, _)| *expires > now);
18-
if self.0.len() >= usize::from(bound) {
19-
let oldest = self
20-
.0
21-
.iter()
22-
.min_by_key(|(_, (expiry, _))| *expiry)
23-
.map(|(k, _)| k.clone());
24-
if let Some(oldest) = oldest {
25-
self.0.remove(&oldest);
26-
}
27-
}
35+
self.resize(bound - 1);
2836
self.0.insert(key, (expires, scores));
2937
}
3038
}

‎crates/tinysecurity-auto/src/cache_tests.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,15 @@ fn cache_expires_at_exact_ttl_and_evicts_bounded_scores() {
1313
cache.put("a".into(), u64::MAX, 10, 1, [1.0; 3]);
1414
assert_eq!(cache.get("a", 0), None);
1515
}
16+
17+
#[test]
18+
fn shrinking_capacity_trims_existing_inventory_before_insertion() {
19+
let mut cache = Cache::default();
20+
for key in ["a", "b", "c"] {
21+
cache.put(key.into(), 0, 100, 3, [1.0; 3]);
22+
}
23+
cache.put("d".into(), 1, 100, 1, [0.0; 3]);
24+
assert_eq!(cache.0.len(), 1);
25+
cache.put("e".into(), 2, 100, 0, [0.0; 3]);
26+
assert!(cache.0.is_empty());
27+
}

‎crates/tinysecurity-auto/src/judge.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ impl Engine {
132132
pub fn reconfigured(&self, config: JudgeConfig) -> Result<Self> {
133133
let mut candidate = Self::new(config, Arc::clone(&self.clock))?;
134134
candidate.state = Arc::clone(&self.state);
135+
candidate
136+
.state
137+
.lock()
138+
.map_err(|_| Error::Budget)?
139+
.1
140+
.resize(candidate.config.cache_entries);
135141
Ok(candidate)
136142
}
137143
/// Score only an already verified pending approval, with fresh policy gates outside.
@@ -175,6 +181,7 @@ impl Engine {
175181
{
176182
return Ok(Some(self.failure(JudgeOutcome2::Budget, thresholds)));
177183
}
184+
state.1.resize(self.config.cache_entries);
178185
state.1.get(&key, now)
179186
};
180187
if let Some(scores) = cached {

‎crates/tinysecurity-auto/src/judge_tests.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,3 +508,32 @@ async fn class_origin_thresholds_invalid_config_and_reconfiguration_cannot_reuse
508508
);
509509
Ok(())
510510
}
511+
512+
#[tokio::test]
513+
async fn legitimate_reconfiguration_shrinks_shared_cache_and_zero_clears_it() -> Result<()> {
514+
let mut cfg = config();
515+
cfg.max_requests = 10;
516+
let engine = Engine::with_client(
517+
cfg.clone(),
518+
fake([1.0, 1.0, 0.0]),
519+
Arc::new(TestClock::default()),
520+
)?;
521+
for call in ["a", "b", "c"] {
522+
let mut request = input();
523+
request.binding.caller.call_id = call.into();
524+
engine
525+
.evaluate(&request, &registry().map_err(|_| Error::Invalid)?)
526+
.await?;
527+
}
528+
assert_eq!(engine.state.lock().map_err(|_| Error::Budget)?.1.len(), 3);
529+
cfg.cache_entries = 1;
530+
let candidate = engine.reconfigured(cfg.clone())?;
531+
assert_eq!(
532+
candidate.state.lock().map_err(|_| Error::Budget)?.1.len(),
533+
1
534+
);
535+
cfg.cache_entries = 0;
536+
let disabled = candidate.reconfigured(cfg)?;
537+
assert_eq!(disabled.state.lock().map_err(|_| Error::Budget)?.1.len(), 0);
538+
Ok(())
539+
}

‎crates/tinysecurity-module/examples/judge_native_contract.rs‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ fn judge(endpoint: String) -> JudgeConfig {
6161
thresholds: vec![],
6262
timeout_ms: 1000,
6363
origins: vec![Origin::Operator],
64-
max_requests: 2,
64+
max_requests: 3,
6565
budget_window_ms: 60_000,
6666
cache_entries: 10,
6767
cache_ttl_ms: 1000,
@@ -120,6 +120,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
120120
verify_fallback(&proxy, &stub, &store).await?;
121121
let mut invalid = configuration.clone();
122122
invalid.judge.as_mut().ok_or("judge")?.endpoint = "http://not-loopback.invalid/jev".into();
123+
invalid.judge.as_mut().ok_or("judge")?.credential = Secret("invalid-candidate-key".into());
123124
assert!(
124125
lifecycle
125126
.reinitialize(&loaded.name, serde_json::to_value(invalid)?)
@@ -128,10 +129,11 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
128129
);
129130
let info: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?;
130131
assert_eq!(info.generation, 2);
132+
verify_retained_scoring(&proxy, &stub).await?;
131133
prompt_failure.store(true, Ordering::SeqCst);
132134
assert!(
133135
proxy
134-
.call::<EvaluateApprovalResponse2>(names::methods::EVALUATE_APPROVAL2, (begin(7, 8),))
136+
.call::<EvaluateApprovalResponse2>(names::methods::EVALUATE_APPROVAL2, (begin(7, 10),))
135137
.await
136138
.is_err()
137139
);
@@ -161,6 +163,64 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
161163
Ok(())
162164
}
163165

166+
async fn verify_retained_scoring(
167+
proxy: &tinybus::Proxy,
168+
stub: &stub::Stub,
169+
) -> std::result::Result<(), Box<dyn std::error::Error>> {
170+
*stub.response.lock().map_err(|_| "stub")? = (200, stub::response([1.0, 1.0, 0.0]));
171+
let retained: EvaluateApprovalResponse2 = proxy
172+
.call(names::methods::EVALUATE_APPROVAL2, (begin(9, 7),))
173+
.await?;
174+
assert_eq!(retained.decision.verdict, Verdict::Allow);
175+
assert_eq!(retained.record.attribution.as_deref(), Some("auto:jev"));
176+
assert!(retained.record.decision_ticket_sha256.is_none());
177+
let evidence = retained
178+
.record
179+
.judge
180+
.as_ref()
181+
.ok_or("retained scorer evidence")?;
182+
assert_eq!(evidence.outcome, JudgeOutcome2::Qualified);
183+
assert_eq!(evidence.scores_millionths, Some([1_000_000, 1_000_000, 0]));
184+
{
185+
let captured = stub.requests.lock().map_err(|_| "stub")?;
186+
assert_eq!(
187+
captured.len(),
188+
3,
189+
"fresh identity must use retained SDK endpoint and credential"
190+
);
191+
assert_eq!(
192+
captured.last().ok_or("retained SDK request")?["model"],
193+
"jev-latest"
194+
);
195+
}
196+
let consumed: ApprovalRow2 = proxy
197+
.call(
198+
names::methods::CONSUME_APPROVAL2,
199+
(ApprovalActionRequest2 {
200+
scope_id: "a".repeat(32),
201+
request_id: retained.record.request_id,
202+
request: begin(9, 8).request,
203+
audit: audit(8),
204+
},),
205+
)
206+
.await?;
207+
assert_eq!(consumed.state, ApprovalLifecycle2::Reserved);
208+
let budget: EvaluateApprovalResponse2 = proxy
209+
.call(names::methods::EVALUATE_APPROVAL2, (begin(6, 9),))
210+
.await?;
211+
assert_eq!(
212+
budget
213+
.record
214+
.judge
215+
.as_ref()
216+
.ok_or("budget evidence")?
217+
.outcome,
218+
JudgeOutcome2::Budget
219+
);
220+
assert_eq!(stub.requests.lock().map_err(|_| "stub")?.len(), 3);
221+
Ok(())
222+
}
223+
164224
fn configuration(
165225
host: &Connection,
166226
root: &std::path::Path,
@@ -297,18 +357,6 @@ async fn verify_fallback(
297357
.await?;
298358
assert_eq!(human.state, ApprovalLifecycle2::Approved);
299359
assert!(human.decision_ticket_sha256.is_some());
300-
let budget: EvaluateApprovalResponse2 = proxy
301-
.call(names::methods::EVALUATE_APPROVAL2, (begin(6, 7),))
302-
.await?;
303-
assert_eq!(
304-
budget
305-
.record
306-
.judge
307-
.as_ref()
308-
.ok_or("budget evidence")?
309-
.outcome,
310-
JudgeOutcome2::Budget
311-
);
312360
Ok(())
313361
}
314362

0 commit comments

Comments
 (0)