Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7173955
test(tinysecurity-auto): cover unknown origin and tighten operator ex…
senamakel Oct 25, 2026
c3742b5
fix(auto): treat unknown origin as grant only
senamakel Oct 25, 2026
465748c
test(approval): cover require_approval overriding whole-tool allowlist
senamakel Oct 25, 2026
46d837d
fix(approval): treat unknown origin as unlabelled and skip approval r…
senamakel Oct 25, 2026
fdc53f8
feat(approval): add cross-scope recent decisions listing
senamakel Oct 25, 2026
86358e7
feat(approval): expose caller session id on approval rows
senamakel Oct 25, 2026
4097241
fix(approval): populate session id on approval rows
senamakel Oct 25, 2026
0bc53cb
feat(approval): add session purge and audit error recording
senamakel Oct 25, 2026
2077afe
fix(approval): handle missing session id in binding validation
senamakel Oct 25, 2026
f397ed7
test: add session id and audit error fields to test fixtures
senamakel Oct 25, 2026
2225ada
fix(approval): honour durable grants when scope requires approval
senamakel Oct 25, 2026
3bc4605
feat(approval): add session purge and audit error recording
senamakel Oct 25, 2026
2a44065
chore(bus): drop purge session request from redacted debug
senamakel Oct 25, 2026
18fa758
test(approval): align scope race tests with pending lifecycle
senamakel Oct 25, 2026
b9dcf98
test(approval): import GrantToolRequest2 in engine scope tests
senamakel Oct 25, 2026
60f58cb
test(approval): cover scope replacement with a distinct policy ref
senamakel Oct 25, 2026
b06c98e
test(bus): cover contract 1.30 methods and compatibility
senamakel Oct 25, 2026
d84608e
feat(bus): register new approval and audit methods
senamakel Oct 25, 2026
dd912b1
test(bus): bump expected contract version to 1.30
senamakel Oct 25, 2026
158cf80
test(module): cover required approval precedence over tool grants
senamakel Oct 25, 2026
80227cf
test(tinysecurity-bus): return result from session purge contract test
senamakel Oct 25, 2026
e72abe7
test(approval): use checked conversion for removed count
senamakel Oct 25, 2026
a49c048
fix(tinysecurity-module): disable approval requirement in scope example
senamakel Oct 25, 2026
f4d14c9
chore(examples): add origin field to approval scope contract example
senamakel Oct 25, 2026
d997830
chore(examples): set saas origin in approval scope contract
senamakel Oct 25, 2026
47827a3
chore(examples): reorder imports in approval scope contract
senamakel Oct 25, 2026
084734a
test(approval): cover purge session debug redaction
senamakel Oct 25, 2026
68c7aee
test(approval): cover session purge and client validation paths
senamakel Oct 25, 2026
cc5facf
test(approval): clone scope and handle in callback assertions
senamakel Oct 25, 2026
316dfa4
test(approval): serve object path in runtime subject test
senamakel Oct 25, 2026
a135181
test(approval): split session purge scope checks into a dedicated test
senamakel Oct 25, 2026
ac6c0d0
test(approval): cover empty init scope list in its own case
senamakel Oct 25, 2026
aa2c54c
test(approval): cover audit error and session purge edge cases
senamakel Oct 25, 2026
3c55e71
test(bus): cover session purge debug redaction
senamakel Oct 25, 2026
ac3e2a0
test(module): cover recent decisions, session purge and audit errors
senamakel Oct 25, 2026
f9cd99e
test(approval): cover default purge_session port failing closed
senamakel Oct 25, 2026
24360da
test(adapter-approval): drive approval through consume and execution …
senamakel Oct 25, 2026
f60f833
style: apply rustfmt formatting to approval tests and example
senamakel Oct 25, 2026
b391a2d
chore(examples): avoid consuming callback name list
senamakel Oct 25, 2026
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
27 changes: 27 additions & 0 deletions crates/tinysecurity-approval/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@ Being the trusted bus peer alone does not make model input a human action.
Tickets are never persisted or printed by the module; only their SHA-256
correlation and sanitized attribution enter durable rows.

Forced, user-requested approval is a host routing mode: the host may start it
only from a validated WebChat request, must suppress all allowlists and
automatic decisions, and must allow only a once choice. A forced request from
any other origin is denied before `EvaluateApproval2`. Detached sub-agent
classification and keeping its approval card visible after the parent turn
ends also remain host responsibilities; the host must preserve the verified
origin when delegating and must not infer it from prompt or tool arguments.
The approval engine owns only the typed origin and durable decision. When a
parked request reaches its deadline, the host should call `ExpireApproval2` so
the row records `expired`; `CancelApproval2` is reserved for explicit host
cancellation.

Rows carry optional `session_id`, copied from the already identifier-redacted
caller thread and checked against that binding when loaded. `ListRecentDecisions2`
reads terminal/approved decisions across currently registered scopes and omits
pending rows. `PurgeSession` receives that same hashed session identifier once
per registered scope; the host store removes only pending rows for the exact
scope/session pair and preserves decision history. The engine verifies each
scope/session echo and caps each callback's removal count.

After an audit sink failure, the host may call `RecordAuditError2` with the raw
failure detail and a new audit correlation. The engine applies the configured
`RedactMode::Text` redactor, then caps the sanitized text to 512 characters,
and CAS-persists only `ApprovalRow2.audit_error`. This metadata update never
changes approval state or grants authority. The host must store/use the
returned sanitized row value and must never persist the raw callback argument.

## Authority and lifecycle

Version-one bindings use domain-separated SHA-256 of bounded original parsed
Expand Down
12 changes: 12 additions & 0 deletions crates/tinysecurity-approval/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ pub(crate) fn parse(record: &ApprovalRecord2, scope: &SubjectScope) -> Result<Ap
|| &row.binding.scope != scope
|| row.binding.binding_version != 1
|| row.binding.origin != row.binding.caller.origin
|| row
.session_id
.as_ref()
.is_some_and(|session| Some(session) != row.binding.caller.thread.as_ref())
|| row
.session_id
.as_ref()
.is_some_and(|session| session.is_empty() || session.len() > 1024)
|| row
.audit_error
.as_ref()
.is_some_and(|error| error.chars().count() > 512)
|| !hex(&row.request_id, 32)
|| !hex(&row.host_instance, 32)
|| !hex(&row.nonce, 32)
Expand Down
131 changes: 124 additions & 7 deletions crates/tinysecurity-approval/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ use tinysecurity_bus::{
ApprovalActionRequest2, ApprovalCasRequest2, ApprovalCasResponse2, ApprovalChoice2,
ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalGrants2,
ApprovalHistoryImport2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2,
ApprovalRecordRequest2, ApprovalRow2, ApprovalScope2, ApprovalScopeOutcome2,
ApprovalScopeReceipt2, AuditCorrelation2, CallerContext, Check, DecideRequest2,
EvaluateApprovalRequest2, EvaluateRequest, HUMAN_ONLY_TOOLS2, HumanDecisionReceipt2,
MAX_APPROVAL_SCOPES2, Origin, PreauthorizeFlowRequest2, RecordExecutionRequest2, RedactMode,
RegisterApprovalScopeRequest2, RetireApprovalScopeRequest2, SubjectScope, ToolCall,
VerifyHumanRequest2,
ApprovalPurgeSessionRequest2, ApprovalRecordRequest2, ApprovalRow2, ApprovalScope2,
ApprovalScopeOutcome2, ApprovalScopeReceipt2, AuditCorrelation2, CallerContext, Check,
DecideRequest2, EvaluateApprovalRequest2, EvaluateRequest, HUMAN_ONLY_TOOLS2,
HumanDecisionReceipt2, MAX_APPROVAL_SCOPES2, Origin, PreauthorizeFlowRequest2,
RecordExecutionRequest2, RedactMode, RegisterApprovalScopeRequest2,
RetireApprovalScopeRequest2, SubjectScope, ToolCall, VerifyHumanRequest2,
};
use tinysecurity_redact::Redactor;
/// Trusted acknowledgement binding an exact row to the untouched actual sink receipt.
Expand Down Expand Up @@ -122,6 +122,15 @@ pub trait Ports: Send + Sync + std::fmt::Debug {
/// # Errors
/// Timeout is indeterminate; conflicting mutation reuse must reject.
async fn cas(&self, request: ApprovalCasRequest2) -> Result<ApprovalCasResponse2>;
/// Remove undecided rows for one exact session in one exact subject.
/// # Errors
/// Rejects an unavailable store or a response that does not echo the request.
async fn purge_session(
&self,
_request: ApprovalPurgeSessionRequest2,
) -> Result<tinysecurity_bus::ApprovalPurgeSessionResponse2> {
Err(Error::Denied)
}
/// Deliver a verified per-user prompt; never a decision.
/// # Errors
/// Rejects unavailable or unverified human channels.
Expand Down Expand Up @@ -255,6 +264,9 @@ impl ScopeEntry {
durable_flow: bool,
durable_tool: bool,
) -> Option<&'static str> {
if self.scope.require_approval && !durable_tool && !durable_flow {
return None;
}
match rule {
tinysecurity_auto::rules::Rule::Approval => self
.grant(
Expand Down Expand Up @@ -532,13 +544,16 @@ impl Engine {
if expiry <= now {
return Err(Error::Denied);
}
let session_id = bound.caller.thread.clone();
let row = ApprovalRow2 {
request_id: binding::opaque()?,
version: 1,
binding: bound,
host_instance: self.instance.clone(),
nonce: binding::opaque()?,
tool_name: self.begin_tool_label(&request.request.call.tool, deadline.is_some())?,
session_id,
audit_error: None,
external_effect,
args_redacted,
summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?,
Expand Down Expand Up @@ -1052,6 +1067,8 @@ impl Engine {
host_instance: self.instance.clone(),
nonce: binding::opaque()?,
tool_name: "imported-history".into(),
session_id: None,
audit_error: None,
external_effect: false,
args_redacted: "{}".into(),
summary: "imported-history".into(),
Expand Down Expand Up @@ -1675,7 +1692,7 @@ impl Engine {
let entry = self.entry(&request.scope_id)?;
// An unlabelled call is approved by a whole-tool grant alone; a flow grant
// added since never keeps its approval alive.
let mut granted = if row.binding.origin == Origin::Unlabelled {
let mut granted = if matches!(row.binding.origin, Origin::Unlabelled | Origin::Unknown) {
entry.tool_grant(&request.request.call.tool).is_some()
} else {
entry
Expand Down Expand Up @@ -1794,6 +1811,41 @@ impl Engine {
self.persist(Some(previous), &row).await?;
Ok(row)
}
/// Persist only a redacted diagnostic after an audit sink failure.
/// This metadata mutation never changes the approval lifecycle or grants authority.
/// # Errors
/// Rejects invalid diagnostics, stale rows, mismatched scopes or unavailable storage.
pub async fn record_audit_error(
&self,
request: tinysecurity_bus::RecordAuditErrorRequest2,
) -> Result<ApprovalRow2> {
if request.audit_error.is_empty() || request.audit_error.len() > 8192 {
return Err(Error::Invalid);
}
let reg = self.registration(&request.scope_id)?;
let mut row = self.row(&reg, &request.request_id).await?;
Self::current(&reg, &row)?;
let sanitized = binding::text(&self.redactor()?, &request.audit_error, RedactMode::Text)?;
let sanitized = if sanitized.chars().count() > 512 {
let head: String = sanitized.chars().take(511).collect();
format!("{head}…")
} else {
sanitized
};
Self::correlation(&request.audit, reg.generation)?;
if row.audit_error.as_deref() == Some(&sanitized) && row.audit == request.audit {
return Ok(row);
}
if row.audit_error.is_some() {
return Err(Error::Conflict);
}
let previous = row.version;
row.version = previous.checked_add(1).ok_or(Error::Invalid)?;
row.audit_error = Some(sanitized);
row.audit = request.audit;
self.persist(Some(previous), &row).await?;
Ok(row)
}
fn current(reg: &ApprovalScope2, row: &ApprovalRow2) -> Result<()> {
if row.binding.generation != reg.generation
|| row.binding.scope != binding::scope_handle(&reg.subject)?
Expand Down Expand Up @@ -1854,6 +1906,71 @@ impl Engine {
pub async fn list(&self, scope_id: &str) -> Result<Vec<ApprovalRow2>> {
self.rows(&self.registration(scope_id)?).await
}
/// Read the newest durable decisions across currently registered scopes.
/// Pending requests are excluded; the result is bounded to 1..=500 rows.
/// # Errors
/// Rejects malformed rows or unavailable scope stores.
pub async fn list_recent_decisions(&self, limit: usize) -> Result<Vec<ApprovalRow2>> {
let scopes = self
.scopes
.read()
.map_err(|_| Error::Unavailable)?
.by_id
.values()
.map(|entry| entry.scope.clone())
.collect::<Vec<_>>();
let mut rows = Vec::new();
for scope in scopes {
rows.extend(
self.rows(&scope)
.await?
.into_iter()
.filter(|row| row.state != ApprovalLifecycle2::Pending),
);
}
rows.sort_by(|left, right| {
right
.created_at_ms
.cmp(&left.created_at_ms)
.then_with(|| left.request_id.cmp(&right.request_id))
});
rows.truncate(limit.clamp(1, 500));
Ok(rows)
}
/// Remove undecided rows belonging to one session across registered subjects.
/// The session identifier is redacted once and matched against stored row hashes.
/// Decided rows remain as durable audit history.
/// # Errors
/// Rejects invalid identifiers, malformed responses or unavailable stores.
pub async fn purge_session(&self, session_id: &str) -> Result<usize> {
if session_id.trim().is_empty() || session_id.len() > 1024 {
return Err(Error::Invalid);
}
let session_id = binding::text(&self.redactor()?, session_id, RedactMode::IdentifierHash)?;
let scopes = self
.scopes
.read()
.map_err(|_| Error::Unavailable)?
.by_id
.values()
.map(|entry| entry.scope.clone())
.collect::<Vec<_>>();
let mut removed = 0_usize;
for scope in scopes {
let request = ApprovalPurgeSessionRequest2 {
scope: binding::scope_handle(&scope.subject)?,
session_id: session_id.clone(),
};
let response = self.ports.purge_session(request.clone()).await?;
request
.verify_response(&response)
.map_err(|_| Error::Invalid)?;
removed = removed
.checked_add(response.removed as usize)
.ok_or(Error::Invalid)?;
}
Ok(removed)
}
/// Resolve pending or approved state as cancelled, retaining its captured scope.
/// # Errors
/// Rejects already terminal, unrelated or unacknowledged transitions.
Expand Down
2 changes: 2 additions & 0 deletions crates/tinysecurity-approval/src/engine_flow_trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ impl Engine {
host_instance: self.instance.clone(),
nonce: binding::opaque()?,
tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?,
session_id: None,
audit_error: None,
external_effect: true,
args_redacted: "{}".into(),
summary: "flow trust".into(),
Expand Down
Loading
Loading