mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
feat(authority): Engine GrantStore and single grant evaluator (APPS-155 slice 1)
Adds core/authority/grants.rs: the grant record (effect, thread/workspace scope, matcher, limits, applies_to, created, state), a file-backed store (approval_grants.json + append-only approval_grants.jsonl for created/used/revoked/expired/exhausted/suspended/resumed), and one evaluator: deny beats allow in every posture and origin; allow needs a listed origin and a posture at or under its ceiling (Full Access/Never never allow); most specific matcher wins, then narrower scope, then newest. Allow grants cannot cover a whole tool class. Workspace grants default to 30 idle days. Expiry, max_uses and clock skew resolve at use time and prefer expiring; an unreadable store is moved aside and means no grants. Thread grants persist but load suspended and reactivate only on resume_thread. Decision receipts gain grant_id and matched_label. Replaces the Runtime approval_grants HashMap (RuntimeApprovalGrant is now a view of the store; wire shape and approval.grant_added/revoked unchanged). The Runtime evaluates through the store, so an active deny now also blocks there. The TUI HashSets are not migrated yet (slice 2), nor is evaluation moved into the pipeline, so Auto-Review override and ACP/automation origin gating are in the evaluator but not yet reachable. Evidence: scripts/dev-cargo.sh check -p codewhale-tui --lib --locked green (6m30s, before a peer's in-flight crate-split moves landed in the shared tree). Test targets could not compile afterwards: the peer's uncommitted move of logging.rs breaks include_str in lib.rs, unrelated to this slice. Three existing runtime_threads tests were updated to the new accessors and are NOT run. Not run: gate, migrations (none added), live anything. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
2ba3cdb571
commit
e537df587b
9 files changed
+1040
-79
No files matched your search
@@ -50,6 +50,10 @@ pub(crate) enum ApprovalReceipt {
|
||||
created_at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
decided_by: Option<ApprovalDecider>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grant_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
matched_label: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,9 +120,24 @@ impl ApprovalReceipt {
|
||||
outcome,
|
||||
created_at: Utc::now(),
|
||||
decided_by,
|
||||
grant_id: None,
|
||||
matched_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_grant(mut self, grant: &crate::core::authority::grants::GrantRef) -> Self {
|
||||
if let Self::Decided {
|
||||
grant_id,
|
||||
matched_label,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*grant_id = Some(grant.grant_id.clone());
|
||||
*matched_label = Some(grant.label.clone());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn approval_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Asked { approval_id, .. } | Self::Decided { approval_id, .. } => approval_id,
|
||||
@@ -359,6 +378,7 @@ impl ApprovalReplay {
|
||||
outcome,
|
||||
created_at,
|
||||
decided_by,
|
||||
..
|
||||
} => {
|
||||
if approval_id != tool_call_id {
|
||||
return Err(format!(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! drift independently.
|
||||
|
||||
pub mod auto_review;
|
||||
pub mod grants;
|
||||
pub mod risk;
|
||||
|
||||
pub use risk::{RiskLevel, classify_risk};
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const GRANT_SCHEMA_VERSION: u32 = 1;
|
||||
const GRANTS_FILE: &str = "approval_grants.json";
|
||||
const GRANT_LOG_FILE: &str = "approval_grants.jsonl";
|
||||
pub const DEFAULT_WORKSPACE_IDLE_TTL_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
const CLOCK_SKEW_TOLERANCE_SECS: i64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrantEffect {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum GrantScope {
|
||||
Thread { thread_id: String },
|
||||
Workspace { workspace_id: String },
|
||||
}
|
||||
|
||||
impl GrantScope {
|
||||
fn narrowness(&self) -> u8 {
|
||||
match self {
|
||||
Self::Thread { .. } => 2,
|
||||
Self::Workspace { .. } => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrantOrigin {
|
||||
Interactive,
|
||||
ChildAgent,
|
||||
Automation,
|
||||
Acp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PostureCeiling {
|
||||
Ask,
|
||||
AutoReview,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GrantPosture {
|
||||
Ask,
|
||||
AutoReview,
|
||||
FullAccess,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrantState {
|
||||
Active,
|
||||
Revoked,
|
||||
Expired,
|
||||
Exhausted,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
/// Who answered. There is deliberately no variant for model output, tool
|
||||
/// text, a bot, or a hosted gate: a grant can only be minted from an
|
||||
/// authenticated human channel.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrantDecider {
|
||||
HumanCard,
|
||||
HumanSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrantSurface {
|
||||
Tui,
|
||||
App,
|
||||
Web,
|
||||
Runtime,
|
||||
Cli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantMatcher {
|
||||
pub tool_class: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exact_digest: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grouping_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub argv_prefix: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub net_host: Option<String>,
|
||||
}
|
||||
|
||||
impl GrantMatcher {
|
||||
fn narrows_beyond_class(&self) -> bool {
|
||||
self.exact_digest.is_some()
|
||||
|| self.grouping_key.is_some()
|
||||
|| !self.argv_prefix.is_empty()
|
||||
|| self.net_host.is_some()
|
||||
}
|
||||
|
||||
fn matches(&self, query: &GrantQuery<'_>) -> bool {
|
||||
self.tool_class == query.tool_class
|
||||
&& self
|
||||
.exact_digest
|
||||
.as_deref()
|
||||
.is_none_or(|digest| digest == query.exact_digest)
|
||||
&& self
|
||||
.grouping_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key == query.grouping_key)
|
||||
&& (self.argv_prefix.is_empty() || query.argv.starts_with(&self.argv_prefix))
|
||||
&& self.net_host.as_deref().is_none_or(|host| {
|
||||
query
|
||||
.net_host
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(host))
|
||||
})
|
||||
}
|
||||
|
||||
fn specificity(&self) -> (u8, usize) {
|
||||
if self.exact_digest.is_some() {
|
||||
(3, 0)
|
||||
} else if !self.argv_prefix.is_empty() {
|
||||
(2, self.argv_prefix.len())
|
||||
} else if self.grouping_key.is_some() || self.net_host.is_some() {
|
||||
(1, 0)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn digest(&self) -> String {
|
||||
let canonical = serde_json::to_vec(self).unwrap_or_default();
|
||||
hex_digest(&canonical)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
if let Some(key) = &self.grouping_key {
|
||||
return format!("{}: {key}", self.tool_class);
|
||||
}
|
||||
if !self.argv_prefix.is_empty() {
|
||||
return format!("{}: {} ...", self.tool_class, self.argv_prefix.join(" "));
|
||||
}
|
||||
if let Some(host) = &self.net_host {
|
||||
return format!("{}: {host}", self.tool_class);
|
||||
}
|
||||
if self.exact_digest.is_some() {
|
||||
return format!("{}: this exact call", self.tool_class);
|
||||
}
|
||||
format!("any {} call", self.tool_class)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantDisplay {
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub tool_name: String,
|
||||
pub redacted_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantLimits {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub idle_ttl_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_uses: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub uses: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantAppliesTo {
|
||||
pub origins: Vec<GrantOrigin>,
|
||||
pub posture_ceiling: PostureCeiling,
|
||||
}
|
||||
|
||||
impl Default for GrantAppliesTo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
origins: vec![GrantOrigin::Interactive],
|
||||
posture_ceiling: PostureCeiling::Ask,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantCreated {
|
||||
pub at: DateTime<Utc>,
|
||||
pub principal: String,
|
||||
pub surface: GrantSurface,
|
||||
pub decider: GrantDecider,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_approval_id: Option<String>,
|
||||
pub engine_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrantRevoked {
|
||||
pub at: DateTime<Utc>,
|
||||
pub by: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Grant {
|
||||
pub grant_id: String,
|
||||
pub schema_v: u32,
|
||||
pub effect: GrantEffect,
|
||||
pub scope: GrantScope,
|
||||
pub matcher: GrantMatcher,
|
||||
pub matcher_digest: String,
|
||||
pub display: GrantDisplay,
|
||||
pub limits: GrantLimits,
|
||||
pub applies_to: GrantAppliesTo,
|
||||
pub created: GrantCreated,
|
||||
pub state: GrantState,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revoked: Option<GrantRevoked>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub suspended_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
fn lapse_reason(&self, now: DateTime<Utc>) -> Option<GrantState> {
|
||||
if self.limits.expires_at.is_some_and(|at| at <= now) {
|
||||
return Some(GrantState::Expired);
|
||||
}
|
||||
if let Some(ttl) = self.limits.idle_ttl_secs {
|
||||
let base = self.limits.last_used_at.unwrap_or(self.created.at);
|
||||
let ttl = Duration::seconds(i64::try_from(ttl).unwrap_or(i64::MAX / 2));
|
||||
let skewed = base - now > Duration::seconds(CLOCK_SKEW_TOLERANCE_SECS);
|
||||
if skewed || base.checked_add_signed(ttl).is_none_or(|end| end <= now) {
|
||||
return Some(GrantState::Expired);
|
||||
}
|
||||
}
|
||||
if self
|
||||
.limits
|
||||
.max_uses
|
||||
.is_some_and(|max| self.limits.uses >= max)
|
||||
{
|
||||
return Some(GrantState::Exhausted);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reference(&self) -> GrantRef {
|
||||
GrantRef {
|
||||
grant_id: self.grant_id.clone(),
|
||||
label: self.display.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a decision receipt names: the grant that answered and its label.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GrantRef {
|
||||
pub grant_id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub struct NewGrant {
|
||||
pub effect: GrantEffect,
|
||||
pub scope: GrantScope,
|
||||
pub matcher: GrantMatcher,
|
||||
pub tool_name: String,
|
||||
pub redacted_summary: String,
|
||||
pub limits: GrantLimits,
|
||||
pub applies_to: GrantAppliesTo,
|
||||
pub principal: String,
|
||||
pub surface: GrantSurface,
|
||||
pub decider: GrantDecider,
|
||||
pub source_approval_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GrantQuery<'a> {
|
||||
pub thread_id: &'a str,
|
||||
pub workspace_id: &'a str,
|
||||
pub origin: GrantOrigin,
|
||||
pub posture: GrantPosture,
|
||||
pub tool_class: &'a str,
|
||||
pub exact_digest: &'a str,
|
||||
pub grouping_key: &'a str,
|
||||
pub argv: &'a [String],
|
||||
pub net_host: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GrantVerdict {
|
||||
Deny(GrantRef),
|
||||
Allow(GrantRef),
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GrantError {
|
||||
#[error("an allow grant must narrow beyond the tool class")]
|
||||
TooBroad,
|
||||
#[error("grant store is degraded: {0}")]
|
||||
Degraded(String),
|
||||
#[error("grant is exhausted, expired or no longer active")]
|
||||
NotUsable,
|
||||
#[error("grant evidence could not be written: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GrantEvent {
|
||||
at: DateTime<Utc>,
|
||||
event: &'static str,
|
||||
grant_id: String,
|
||||
effect: GrantEffect,
|
||||
scope: GrantScope,
|
||||
label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
uses: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
grant: Option<Grant>,
|
||||
}
|
||||
|
||||
impl GrantEvent {
|
||||
fn new(event: &'static str, grant: &Grant) -> Self {
|
||||
Self {
|
||||
at: Utc::now(),
|
||||
event,
|
||||
grant_id: grant.grant_id.clone(),
|
||||
effect: grant.effect,
|
||||
scope: grant.scope.clone(),
|
||||
label: grant.display.label.clone(),
|
||||
by: None,
|
||||
reason: None,
|
||||
uses: None,
|
||||
grant: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct GrantFile {
|
||||
schema_v: u32,
|
||||
grants: Vec<Grant>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
dir: Option<PathBuf>,
|
||||
grants: Vec<Grant>,
|
||||
degraded: Option<String>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn persist(&self, grants: &[Grant], events: &[GrantEvent]) -> std::io::Result<()> {
|
||||
let Some(dir) = &self.dir else {
|
||||
return Ok(());
|
||||
};
|
||||
if !events.is_empty() {
|
||||
let mut log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(dir.join(GRANT_LOG_FILE))?;
|
||||
let mut buf = Vec::new();
|
||||
for event in events {
|
||||
serde_json::to_writer(&mut buf, event).map_err(std::io::Error::other)?;
|
||||
buf.push(b'\n');
|
||||
}
|
||||
log.write_all(&buf)?;
|
||||
log.sync_data()?;
|
||||
}
|
||||
let file = GrantFile {
|
||||
schema_v: GRANT_SCHEMA_VERSION,
|
||||
grants: grants.to_vec(),
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&file).map_err(std::io::Error::other)?;
|
||||
crate::utils::write_atomic(&dir.join(GRANTS_FILE), &bytes)
|
||||
}
|
||||
|
||||
fn commit(&mut self, grants: Vec<Grant>, events: Vec<GrantEvent>) -> std::io::Result<()> {
|
||||
self.persist(&grants, &events)?;
|
||||
self.grants = grants;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_tightening(&mut self, grants: Vec<Grant>, events: Vec<GrantEvent>) {
|
||||
if let Err(error) = self.persist(&grants, &events) {
|
||||
tracing::warn!(target: "approval", %error, "grant revocation evidence could not be written");
|
||||
}
|
||||
self.grants = grants;
|
||||
}
|
||||
|
||||
fn sweep(&mut self, now: DateTime<Utc>) {
|
||||
let mut grants = self.grants.clone();
|
||||
let mut events = Vec::new();
|
||||
for grant in &mut grants {
|
||||
if grant.state != GrantState::Active {
|
||||
continue;
|
||||
}
|
||||
if let Some(state) = grant.lapse_reason(now) {
|
||||
grant.state = state;
|
||||
let name = if state == GrantState::Expired {
|
||||
"expired"
|
||||
} else {
|
||||
"exhausted"
|
||||
};
|
||||
events.push(GrantEvent::new(name, grant));
|
||||
}
|
||||
}
|
||||
if !events.is_empty() {
|
||||
self.commit_tightening(grants, events);
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(
|
||||
&mut self,
|
||||
select: impl Fn(&Grant) -> bool,
|
||||
from: GrantState,
|
||||
to: GrantState,
|
||||
event: &'static str,
|
||||
by: &str,
|
||||
reason: &str,
|
||||
tighten: bool,
|
||||
) -> Vec<Grant> {
|
||||
let mut grants = self.grants.clone();
|
||||
let mut events = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
for grant in &mut grants {
|
||||
if grant.state != from || !select(grant) {
|
||||
continue;
|
||||
}
|
||||
grant.state = to;
|
||||
match to {
|
||||
GrantState::Revoked => {
|
||||
grant.revoked = Some(GrantRevoked {
|
||||
at: Utc::now(),
|
||||
by: by.to_string(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
}
|
||||
GrantState::Suspended => grant.suspended_reason = Some(reason.to_string()),
|
||||
_ => grant.suspended_reason = None,
|
||||
}
|
||||
let mut record = GrantEvent::new(event, grant);
|
||||
record.by = Some(by.to_string());
|
||||
record.reason = Some(reason.to_string());
|
||||
events.push(record);
|
||||
changed.push(grant.clone());
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
if tighten {
|
||||
self.commit_tightening(grants, events);
|
||||
changed
|
||||
} else if self.commit(grants, events).is_ok() {
|
||||
changed
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single grant authority: records, matching, precedence, expiry,
|
||||
/// revocation and receipts. Cloning shares the same store.
|
||||
#[derive(Clone)]
|
||||
pub struct GrantStore {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
impl GrantStore {
|
||||
/// Open the file-backed store under `dir`. An unreadable or corrupt store
|
||||
/// is moved aside and treated as holding no grants: the store never
|
||||
/// allows from a file it cannot read.
|
||||
#[must_use]
|
||||
pub fn open(dir: &Path) -> Self {
|
||||
let mut inner = Inner {
|
||||
dir: Some(dir.to_path_buf()),
|
||||
grants: Vec::new(),
|
||||
degraded: None,
|
||||
};
|
||||
let path = dir.join(GRANTS_FILE);
|
||||
match fs::read(&path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<GrantFile>(&bytes) {
|
||||
Ok(file) if file.schema_v <= GRANT_SCHEMA_VERSION => inner.grants = file.grants,
|
||||
Ok(file) => {
|
||||
inner.degraded = Some(format!("grant store schema {} is newer", file.schema_v));
|
||||
inner.dir = None;
|
||||
}
|
||||
Err(error) => {
|
||||
let aside = dir.join(format!(
|
||||
"{GRANTS_FILE}.corrupt-{}",
|
||||
Utc::now().timestamp_millis()
|
||||
));
|
||||
let _ = fs::rename(&path, aside);
|
||||
inner.degraded = Some(format!("grant store was unreadable: {error}"));
|
||||
}
|
||||
},
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
inner.degraded = Some(format!("grant store could not be read: {error}"));
|
||||
inner.dir = None;
|
||||
}
|
||||
}
|
||||
if let Some(reason) = &inner.degraded {
|
||||
tracing::warn!(target: "approval", %reason, "standing grants are unavailable; every call asks");
|
||||
}
|
||||
inner.transition(
|
||||
|grant| matches!(grant.scope, GrantScope::Thread { .. }),
|
||||
GrantState::Active,
|
||||
GrantState::Suspended,
|
||||
"suspended",
|
||||
"engine",
|
||||
"restart: awaiting authenticated resume",
|
||||
true,
|
||||
);
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a grant. Allow grants cannot cover a whole tool class, and an
|
||||
/// identical live grant is returned instead of duplicated.
|
||||
pub fn create(&self, new: NewGrant) -> Result<Grant, GrantError> {
|
||||
if new.effect == GrantEffect::Allow && !new.matcher.narrows_beyond_class() {
|
||||
return Err(GrantError::TooBroad);
|
||||
}
|
||||
let mut inner = self.inner.lock();
|
||||
if let Some(reason) = &inner.degraded
|
||||
&& inner.dir.is_none()
|
||||
{
|
||||
return Err(GrantError::Degraded(reason.clone()));
|
||||
}
|
||||
let now = Utc::now();
|
||||
inner.sweep(now);
|
||||
let matcher_digest = new.matcher.digest();
|
||||
if let Some(existing) = inner.grants.iter().find(|grant| {
|
||||
grant.state == GrantState::Active
|
||||
&& grant.effect == new.effect
|
||||
&& grant.scope == new.scope
|
||||
&& grant.matcher_digest == matcher_digest
|
||||
}) {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let mut limits = new.limits;
|
||||
if limits.idle_ttl_secs.is_none() && matches!(new.scope, GrantScope::Workspace { .. }) {
|
||||
limits.idle_ttl_secs = Some(DEFAULT_WORKSPACE_IDLE_TTL_SECS);
|
||||
}
|
||||
let grant = Grant {
|
||||
grant_id: format!("grt_{}", uuid::Uuid::new_v4().simple()),
|
||||
schema_v: GRANT_SCHEMA_VERSION,
|
||||
effect: new.effect,
|
||||
scope: new.scope,
|
||||
display: GrantDisplay {
|
||||
label: new.matcher.label(),
|
||||
tool_name: new.tool_name,
|
||||
redacted_summary: new.redacted_summary,
|
||||
},
|
||||
matcher: new.matcher,
|
||||
matcher_digest,
|
||||
limits,
|
||||
applies_to: new.applies_to,
|
||||
created: GrantCreated {
|
||||
at: now,
|
||||
principal: new.principal,
|
||||
surface: new.surface,
|
||||
decider: new.decider,
|
||||
source_approval_id: new.source_approval_id,
|
||||
engine_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
state: GrantState::Active,
|
||||
revoked: None,
|
||||
suspended_reason: None,
|
||||
};
|
||||
let mut event = GrantEvent::new("created", &grant);
|
||||
event.by = Some(grant.created.principal.clone());
|
||||
event.grant = Some(grant.clone());
|
||||
let mut grants = inner.grants.clone();
|
||||
grants.push(grant.clone());
|
||||
inner.commit(grants, vec![event])?;
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
/// Decide a call against the live grants. Deny beats allow in every
|
||||
/// posture and for every origin; an allow needs a live posture at or
|
||||
/// under its ceiling and an origin it lists. Among allows the most
|
||||
/// specific matcher wins, then the narrower scope, then the newest.
|
||||
/// This only reads; call [`Self::record_use`] when the allow is applied.
|
||||
#[must_use]
|
||||
pub fn evaluate(&self, query: &GrantQuery<'_>) -> GrantVerdict {
|
||||
let mut inner = self.inner.lock();
|
||||
inner.sweep(Utc::now());
|
||||
let in_scope = |grant: &&Grant| {
|
||||
grant.state == GrantState::Active
|
||||
&& match &grant.scope {
|
||||
GrantScope::Thread { thread_id } => thread_id == query.thread_id,
|
||||
GrantScope::Workspace { workspace_id } => workspace_id == query.workspace_id,
|
||||
}
|
||||
&& grant.matcher.matches(query)
|
||||
};
|
||||
let best = |effect: GrantEffect, usable: &dyn Fn(&Grant) -> bool| {
|
||||
inner
|
||||
.grants
|
||||
.iter()
|
||||
.filter(in_scope)
|
||||
.filter(|grant| grant.effect == effect && usable(grant))
|
||||
.max_by_key(|grant| {
|
||||
(
|
||||
grant.matcher.specificity(),
|
||||
grant.scope.narrowness(),
|
||||
grant.created.at,
|
||||
)
|
||||
})
|
||||
.map(Grant::reference)
|
||||
};
|
||||
if let Some(deny) = best(GrantEffect::Deny, &|_| true) {
|
||||
return GrantVerdict::Deny(deny);
|
||||
}
|
||||
let allow = best(GrantEffect::Allow, &|grant| {
|
||||
grant.applies_to.origins.contains(&query.origin)
|
||||
&& match query.posture {
|
||||
GrantPosture::Ask => true,
|
||||
GrantPosture::AutoReview => {
|
||||
grant.applies_to.posture_ceiling == PostureCeiling::AutoReview
|
||||
}
|
||||
GrantPosture::FullAccess | GrantPosture::Never => false,
|
||||
}
|
||||
});
|
||||
allow.map_or(GrantVerdict::NoMatch, GrantVerdict::Allow)
|
||||
}
|
||||
|
||||
/// Count one use of an allow grant under the store lock. Fails closed:
|
||||
/// an exhausted, lapsed or unwritable grant is not usable and the caller
|
||||
/// must ask instead.
|
||||
pub fn record_use(&self, grant_id: &str) -> Result<(), GrantError> {
|
||||
let mut inner = self.inner.lock();
|
||||
let now = Utc::now();
|
||||
inner.sweep(now);
|
||||
let mut grants = inner.grants.clone();
|
||||
let Some(grant) = grants
|
||||
.iter_mut()
|
||||
.find(|grant| grant.grant_id == grant_id && grant.state == GrantState::Active)
|
||||
else {
|
||||
return Err(GrantError::NotUsable);
|
||||
};
|
||||
grant.limits.uses = grant.limits.uses.saturating_add(1);
|
||||
grant.limits.last_used_at = Some(now);
|
||||
let mut events = Vec::new();
|
||||
if grant.limits.uses == 1 {
|
||||
let mut used = GrantEvent::new("used", grant);
|
||||
used.uses = Some(1);
|
||||
events.push(used);
|
||||
}
|
||||
if grant
|
||||
.limits
|
||||
.max_uses
|
||||
.is_some_and(|max| grant.limits.uses >= max)
|
||||
{
|
||||
grant.state = GrantState::Exhausted;
|
||||
let mut done = GrantEvent::new("exhausted", grant);
|
||||
done.uses = Some(grant.limits.uses);
|
||||
events.push(done);
|
||||
}
|
||||
inner.commit(grants, events)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke one active or suspended grant, optionally only inside `scope`.
|
||||
pub fn revoke(
|
||||
&self,
|
||||
grant_id: &str,
|
||||
scope: Option<&GrantScope>,
|
||||
by: &str,
|
||||
reason: &str,
|
||||
) -> Option<Grant> {
|
||||
let mut inner = self.inner.lock();
|
||||
let select = |grant: &Grant| {
|
||||
grant.grant_id == grant_id && scope.is_none_or(|scope| &grant.scope == scope)
|
||||
};
|
||||
let mut done = inner.transition(
|
||||
select,
|
||||
GrantState::Active,
|
||||
GrantState::Revoked,
|
||||
"revoked",
|
||||
by,
|
||||
reason,
|
||||
true,
|
||||
);
|
||||
if done.is_empty() {
|
||||
done = inner.transition(
|
||||
select,
|
||||
GrantState::Suspended,
|
||||
GrantState::Revoked,
|
||||
"revoked",
|
||||
by,
|
||||
reason,
|
||||
true,
|
||||
);
|
||||
}
|
||||
done.pop()
|
||||
}
|
||||
|
||||
/// End every grant of a thread (archive or delete).
|
||||
pub fn revoke_thread(&self, thread_id: &str, by: &str, reason: &str) -> Vec<Grant> {
|
||||
let mut inner = self.inner.lock();
|
||||
let select = |grant: &Grant| matches!(&grant.scope, GrantScope::Thread { thread_id: id } if id == thread_id);
|
||||
let mut done = inner.transition(
|
||||
select,
|
||||
GrantState::Active,
|
||||
GrantState::Revoked,
|
||||
"revoked",
|
||||
by,
|
||||
reason,
|
||||
true,
|
||||
);
|
||||
done.extend(inner.transition(
|
||||
select,
|
||||
GrantState::Suspended,
|
||||
GrantState::Revoked,
|
||||
"revoked",
|
||||
by,
|
||||
reason,
|
||||
true,
|
||||
));
|
||||
done
|
||||
}
|
||||
|
||||
/// Reactivate a thread's grants suspended by a restart. Call only from an
|
||||
/// authenticated resume; rehydrating a wait never restores authority.
|
||||
pub fn resume_thread(&self, thread_id: &str) -> Vec<Grant> {
|
||||
self.inner.lock().transition(
|
||||
|grant| matches!(&grant.scope, GrantScope::Thread { thread_id: id } if id == thread_id),
|
||||
GrantState::Suspended,
|
||||
GrantState::Active,
|
||||
"resumed",
|
||||
"authenticated_resume",
|
||||
"thread resumed by an authenticated client",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Live (active) grants of one thread, oldest first.
|
||||
#[must_use]
|
||||
pub fn thread_grants(&self, thread_id: &str) -> Vec<Grant> {
|
||||
let mut inner = self.inner.lock();
|
||||
inner.sweep(Utc::now());
|
||||
inner
|
||||
.grants
|
||||
.iter()
|
||||
.filter(|grant| {
|
||||
grant.state == GrantState::Active
|
||||
&& matches!(&grant.scope, GrantScope::Thread { thread_id: id } if id == thread_id)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable id for a workspace: the git root (or the directory itself) as a
|
||||
/// canonical real path, never the path string a client supplied.
|
||||
#[must_use]
|
||||
pub fn workspace_id(workspace: &Path) -> String {
|
||||
let real = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
let root = real
|
||||
.ancestors()
|
||||
.find(|dir| dir.join(".git").exists())
|
||||
.unwrap_or(&real);
|
||||
format!(
|
||||
"ws_{}",
|
||||
&hex_digest(root.to_string_lossy().as_bytes())[..24]
|
||||
)
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -9326,8 +9326,8 @@ impl MockEngineHandle {
|
||||
Option<crate::approval_log::ApprovalDecider>,
|
||||
)> {
|
||||
Some(match self.rx_approval.recv().await? {
|
||||
ApprovalDecision::Approved { id, by } => (MockApprovalEvent::Approved { id }, Some(by)),
|
||||
ApprovalDecision::Denied { id, by } => (MockApprovalEvent::Denied { id }, Some(by)),
|
||||
ApprovalDecision::Approved { id, by, .. } => (MockApprovalEvent::Approved { id }, Some(by)),
|
||||
ApprovalDecision::Denied { id, by, .. } => (MockApprovalEvent::Denied { id }, Some(by)),
|
||||
ApprovalDecision::TimedOut { id } => (MockApprovalEvent::TimedOut { id }, None),
|
||||
ApprovalDecision::Unavailable { id } => (MockApprovalEvent::Unavailable { id }, None),
|
||||
ApprovalDecision::RetryWithPolicy { id, policy, by } => {
|
||||
|
||||
@@ -36,16 +36,19 @@ fn wait_announcement(what: &str, tool_id: &str, waited: Duration) -> String {
|
||||
}
|
||||
|
||||
use super::Engine;
|
||||
use crate::core::authority::grants::GrantRef;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ApprovalDecision {
|
||||
Approved {
|
||||
id: String,
|
||||
by: ApprovalDecider,
|
||||
grant: Option<GrantRef>,
|
||||
},
|
||||
Denied {
|
||||
id: String,
|
||||
by: ApprovalDecider,
|
||||
grant: Option<GrantRef>,
|
||||
},
|
||||
/// The interactive card expired unanswered (#6101): the configured
|
||||
/// bound denied the call, not the operator.
|
||||
@@ -198,10 +201,24 @@ impl Engine {
|
||||
outcome: ApprovalOutcome,
|
||||
decided_by: Option<ApprovalDecider>,
|
||||
) -> Result<(), ToolError> {
|
||||
self.commit_approval_receipt(ApprovalReceipt::decided_with(tool_id, outcome, decided_by))
|
||||
self.commit_approval_decision(tool_id, outcome, decided_by, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_approval_decision(
|
||||
&self,
|
||||
tool_id: &str,
|
||||
outcome: ApprovalOutcome,
|
||||
decided_by: Option<ApprovalDecider>,
|
||||
grant: Option<&GrantRef>,
|
||||
) -> Result<(), ToolError> {
|
||||
let mut receipt = ApprovalReceipt::decided_with(tool_id, outcome, decided_by);
|
||||
if let Some(grant) = grant {
|
||||
receipt = receipt.with_grant(grant);
|
||||
}
|
||||
self.commit_approval_receipt(receipt).await
|
||||
}
|
||||
|
||||
pub(super) async fn request_tool_approval(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
@@ -374,12 +391,12 @@ impl Engine {
|
||||
continue;
|
||||
}
|
||||
match decision {
|
||||
ApprovalDecision::Approved { id, by } if id == tool_id => {
|
||||
self.commit_approval_outcome(tool_id, ApprovalOutcome::ApprovedOnce, Some(by)).await?;
|
||||
ApprovalDecision::Approved { id, by, grant } if id == tool_id => {
|
||||
self.commit_approval_decision(tool_id, ApprovalOutcome::ApprovedOnce, Some(by), grant.as_ref()).await?;
|
||||
return Ok(ApprovalResult::Approved(by));
|
||||
}
|
||||
ApprovalDecision::Denied { id, by } if id == tool_id => {
|
||||
self.commit_approval_outcome(tool_id, ApprovalOutcome::Denied, Some(by)).await?;
|
||||
ApprovalDecision::Denied { id, by, grant } if id == tool_id => {
|
||||
self.commit_approval_decision(tool_id, ApprovalOutcome::Denied, Some(by), grant.as_ref()).await?;
|
||||
return Ok(ApprovalResult::Denied);
|
||||
}
|
||||
ApprovalDecision::TimedOut { id } if id == tool_id => {
|
||||
|
||||
@@ -593,10 +593,10 @@ impl EngineHandle {
|
||||
async fn send_approval(&self, decision: ApprovalDecision) -> Result<()> {
|
||||
use crate::tools::subagent::ChildApprovalOutcome;
|
||||
let child = match &decision {
|
||||
ApprovalDecision::Approved { id, by } => {
|
||||
ApprovalDecision::Approved { id, by, .. } => {
|
||||
Some((id, ChildApprovalOutcome::Approved { by: *by }))
|
||||
}
|
||||
ApprovalDecision::Denied { id, by } => {
|
||||
ApprovalDecision::Denied { id, by, .. } => {
|
||||
Some((id, ChildApprovalOutcome::Denied { by: *by }))
|
||||
}
|
||||
// An agent has no timeout outcome of its own (#6101): an expired
|
||||
@@ -637,8 +637,28 @@ impl EngineHandle {
|
||||
id: impl Into<String>,
|
||||
by: ApprovalDecider,
|
||||
) -> Result<()> {
|
||||
self.send_approval(ApprovalDecision::Approved { id: id.into(), by })
|
||||
.await
|
||||
self.send_approval(ApprovalDecision::Approved {
|
||||
id: id.into(),
|
||||
by,
|
||||
grant: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Approve a pending tool call because a standing grant matched; the
|
||||
/// approval receipt names the grant.
|
||||
pub async fn approve_tool_call_by_grant(
|
||||
&self,
|
||||
id: impl Into<String>,
|
||||
by: ApprovalDecider,
|
||||
grant: crate::core::authority::grants::GrantRef,
|
||||
) -> Result<()> {
|
||||
self.send_approval(ApprovalDecision::Approved {
|
||||
id: id.into(),
|
||||
by,
|
||||
grant: Some(grant),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deny a pending tool call because a person said no.
|
||||
@@ -652,8 +672,27 @@ impl EngineHandle {
|
||||
id: impl Into<String>,
|
||||
by: ApprovalDecider,
|
||||
) -> Result<()> {
|
||||
self.send_approval(ApprovalDecision::Denied { id: id.into(), by })
|
||||
.await
|
||||
self.send_approval(ApprovalDecision::Denied {
|
||||
id: id.into(),
|
||||
by,
|
||||
grant: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deny a pending tool call because a standing deny grant matched.
|
||||
pub async fn deny_tool_call_by_grant(
|
||||
&self,
|
||||
id: impl Into<String>,
|
||||
by: ApprovalDecider,
|
||||
grant: crate::core::authority::grants::GrantRef,
|
||||
) -> Result<()> {
|
||||
self.send_approval(ApprovalDecision::Denied {
|
||||
id: id.into(),
|
||||
by,
|
||||
grant: Some(grant),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deny a pending tool call because its interactive approval card
|
||||
|
||||
@@ -9695,6 +9695,8 @@ async fn approvals_endpoint_lists_decided_and_pending_newest_first() -> Result<(
|
||||
outcome: ApprovalOutcome::Denied,
|
||||
created_at: at(11),
|
||||
decided_by: Some(crate::approval_log::ApprovalDecider::User),
|
||||
grant_id: None,
|
||||
matched_label: None,
|
||||
},
|
||||
)?;
|
||||
store.append(
|
||||
|
||||
@@ -5334,9 +5334,10 @@ pub struct PendingApprovalRequest {
|
||||
/// The grant covers later calls of the same tool and argument class (the
|
||||
/// approval grouping key) on this thread, for the life of this Runtime
|
||||
/// process or until the thread is archived or deleted. It never changes the
|
||||
/// thread's permission posture, and it can be revoked. Known limit: grants
|
||||
/// are in memory only, so a Runtime restart forgets them and the next
|
||||
/// matching call prompts again (fail closed).
|
||||
/// thread's permission posture, and it can be revoked. Grants persist in the
|
||||
/// Engine grant store; after a Runtime restart they stay suspended until the
|
||||
/// thread is resumed by an authenticated client, and the next matching call
|
||||
/// prompts again until then (fail closed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeApprovalGrant {
|
||||
/// Runtime-minted `grant_<32 hex>`; the revoke endpoint accepts only this.
|
||||
@@ -5349,6 +5350,22 @@ pub struct RuntimeApprovalGrant {
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RuntimeApprovalGrant {
|
||||
fn from_grant(grant: &crate::core::authority::grants::Grant) -> Self {
|
||||
Self {
|
||||
grant_id: grant.grant_id.clone(),
|
||||
tool_name: grant.display.tool_name.clone(),
|
||||
scope: grant.matcher.grouping_key.clone().unwrap_or_default(),
|
||||
summary: grant.display.redacted_summary.clone(),
|
||||
granted_at: grant.created.at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_tool_class(tool_name: &str) -> String {
|
||||
format!("{:?}", crate::core::authority::get_tool_category(tool_name)).to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingUserInputRequest {
|
||||
pub id: String,
|
||||
@@ -6255,7 +6272,7 @@ pub struct RuntimeThreadManager {
|
||||
Arc<parking_lot::Mutex<Option<crate::automation_manager::SharedAutomationManager>>>,
|
||||
pending_approvals: Arc<parking_lot::Mutex<HashMap<String, PendingApprovalEntry>>>,
|
||||
/// Session approval grants per thread id.
|
||||
approval_grants: Arc<parking_lot::Mutex<HashMap<String, Vec<RuntimeApprovalGrant>>>>,
|
||||
grants: crate::core::authority::grants::GrantStore,
|
||||
pending_user_inputs: Arc<parking_lot::Mutex<HashMap<(String, String), PendingUserInputEntry>>>,
|
||||
pending_dynamic_tools: Arc<parking_lot::Mutex<HashMap<String, PendingDynamicToolEntry>>>,
|
||||
recovery_receipts: Arc<parking_lot::Mutex<HashMap<String, Vec<RecoveredTurnReceipt>>>>,
|
||||
@@ -7679,6 +7696,7 @@ impl RuntimeThreadManager {
|
||||
let store = RuntimeThreadStore::open(manager_cfg.data_dir.clone())?;
|
||||
crate::config::initialize_cloud_facts(&config);
|
||||
let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
let grants = crate::core::authority::grants::GrantStore::open(&manager_cfg.data_dir);
|
||||
let manager = Self {
|
||||
host_profile,
|
||||
config: Arc::new(parking_lot::RwLock::new(config)),
|
||||
@@ -7701,7 +7719,7 @@ impl RuntimeThreadManager {
|
||||
task_execution_lease: Arc::new(parking_lot::Mutex::new(None)),
|
||||
automations: Arc::new(parking_lot::Mutex::new(None)),
|
||||
pending_approvals: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
approval_grants: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
grants,
|
||||
pending_user_inputs: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
pending_dynamic_tools: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
recovery_receipts: Arc::new(parking_lot::Mutex::new(HashMap::new())),
|
||||
@@ -8637,20 +8655,11 @@ impl RuntimeThreadManager {
|
||||
/// Live session approval grants on `thread_id`, oldest first.
|
||||
#[must_use]
|
||||
pub fn approval_grants_for_thread(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
|
||||
self.approval_grants
|
||||
.lock()
|
||||
.get(thread_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn session_grant_for(&self, thread_id: &str, scope: &str) -> Option<RuntimeApprovalGrant> {
|
||||
self.approval_grants
|
||||
.lock()
|
||||
.get(thread_id)?
|
||||
self.grants
|
||||
.thread_grants(thread_id)
|
||||
.iter()
|
||||
.find(|grant| grant.scope == scope)
|
||||
.cloned()
|
||||
.map(RuntimeApprovalGrant::from_grant)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record "allow for this conversation" as a grant scoped to the tool and
|
||||
@@ -8669,9 +8678,13 @@ impl RuntimeThreadManager {
|
||||
scope: &str,
|
||||
summary: &str,
|
||||
) -> Option<RuntimeApprovalGrant> {
|
||||
use crate::core::authority::grants::{
|
||||
GrantAppliesTo, GrantDecider, GrantEffect, GrantLimits, GrantMatcher, GrantScope,
|
||||
GrantSurface, NewGrant,
|
||||
};
|
||||
let grant = {
|
||||
// Same order as update_thread's archive path (thread_mutation,
|
||||
// then approval_grants), so archive and record cannot interleave.
|
||||
// then the grant store), so archive and record cannot interleave.
|
||||
let _thread_mutation = self.store.thread_mutation.lock();
|
||||
let live = self
|
||||
.store
|
||||
@@ -8680,20 +8693,40 @@ impl RuntimeThreadManager {
|
||||
if !live {
|
||||
return None;
|
||||
}
|
||||
let mut grants = self.approval_grants.lock();
|
||||
let thread_grants = grants.entry(thread_id.to_string()).or_default();
|
||||
if let Some(existing) = thread_grants.iter().find(|grant| grant.scope == scope) {
|
||||
return Some(existing.clone());
|
||||
if let Some(existing) = self
|
||||
.grants
|
||||
.thread_grants(thread_id)
|
||||
.iter()
|
||||
.find(|grant| grant.matcher.grouping_key.as_deref() == Some(scope))
|
||||
{
|
||||
return Some(RuntimeApprovalGrant::from_grant(existing));
|
||||
}
|
||||
let grant = RuntimeApprovalGrant {
|
||||
grant_id: format!("grant_{}", Uuid::new_v4().simple()),
|
||||
let created = self.grants.create(NewGrant {
|
||||
effect: GrantEffect::Allow,
|
||||
scope: GrantScope::Thread {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
matcher: GrantMatcher {
|
||||
tool_class: grant_tool_class(tool_name),
|
||||
grouping_key: Some(scope.to_string()),
|
||||
..GrantMatcher::default()
|
||||
},
|
||||
tool_name: tool_name.to_string(),
|
||||
scope: scope.to_string(),
|
||||
summary: summary.to_string(),
|
||||
granted_at: Utc::now(),
|
||||
};
|
||||
thread_grants.push(grant.clone());
|
||||
grant
|
||||
redacted_summary: summary.to_string(),
|
||||
limits: GrantLimits::default(),
|
||||
applies_to: GrantAppliesTo::default(),
|
||||
principal: "authenticated_client".to_string(),
|
||||
surface: GrantSurface::Runtime,
|
||||
decider: GrantDecider::HumanCard,
|
||||
source_approval_id: None,
|
||||
});
|
||||
match created {
|
||||
Ok(grant) => RuntimeApprovalGrant::from_grant(&grant),
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "approval", %error, "session grant was not recorded");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
@@ -8707,36 +8740,32 @@ impl RuntimeThreadManager {
|
||||
Some(grant)
|
||||
}
|
||||
|
||||
/// Remove every session grant on `thread_id` and return them. Archiving or
|
||||
/// End every session grant on `thread_id` and return them. Archiving or
|
||||
/// deleting a thread calls this, so a grant never outlives the
|
||||
/// conversation it was given in.
|
||||
fn take_approval_grants(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
|
||||
self.approval_grants
|
||||
.lock()
|
||||
.remove(thread_id)
|
||||
.unwrap_or_default()
|
||||
self.grants
|
||||
.revoke_thread(thread_id, "runtime", "thread archived or deleted")
|
||||
.iter()
|
||||
.map(RuntimeApprovalGrant::from_grant)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Revoke one session grant. Returns `false` when the thread holds no
|
||||
/// grant with that id. The next matching call prompts again.
|
||||
pub async fn revoke_approval_grant(&self, thread_id: &str, grant_id: &str) -> Result<bool> {
|
||||
let revoked = {
|
||||
let mut grants = self.approval_grants.lock();
|
||||
let Some(thread_grants) = grants.get_mut(thread_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(index) = thread_grants
|
||||
.iter()
|
||||
.position(|grant| grant.grant_id == grant_id)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let revoked = thread_grants.remove(index);
|
||||
if thread_grants.is_empty() {
|
||||
grants.remove(thread_id);
|
||||
}
|
||||
revoked
|
||||
let scope = crate::core::authority::grants::GrantScope::Thread {
|
||||
thread_id: thread_id.to_string(),
|
||||
};
|
||||
let Some(revoked) = self.grants.revoke(
|
||||
grant_id,
|
||||
Some(&scope),
|
||||
"authenticated_client",
|
||||
"revoked by an authenticated client",
|
||||
) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let revoked = RuntimeApprovalGrant::from_grant(&revoked);
|
||||
self.emit_event(
|
||||
thread_id,
|
||||
None,
|
||||
@@ -11855,6 +11884,7 @@ impl RuntimeThreadManager {
|
||||
pub async fn resume_thread(&self, id: &str) -> Result<ThreadRecord> {
|
||||
let thread = self.get_thread(id).await?;
|
||||
self.ensure_engine_loaded(&thread).await?;
|
||||
self.grants.resume_thread(id);
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
@@ -17596,7 +17626,50 @@ impl RuntimeThreadManager {
|
||||
ApprovalRequestDisposition, TurnAuthority,
|
||||
resolve_approval_request_disposition,
|
||||
};
|
||||
let grant = self.session_grant_for(&thread_id, &approval_grouping_key);
|
||||
let grant_workspace_id = match summary_workspace.clone() {
|
||||
Some(workspace) => tokio::task::spawn_blocking(move || {
|
||||
crate::core::authority::grants::workspace_id(&workspace)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
let grant_posture = if auto_approve {
|
||||
crate::core::authority::grants::GrantPosture::FullAccess
|
||||
} else {
|
||||
match approval_mode {
|
||||
codewhale_execpolicy::ApprovalMode::Auto => {
|
||||
crate::core::authority::grants::GrantPosture::AutoReview
|
||||
}
|
||||
codewhale_execpolicy::ApprovalMode::Never => {
|
||||
crate::core::authority::grants::GrantPosture::Never
|
||||
}
|
||||
_ => crate::core::authority::grants::GrantPosture::Ask,
|
||||
}
|
||||
};
|
||||
let grant_class = grant_tool_class(&tool_name);
|
||||
let grant_verdict = self.grants.evaluate(
|
||||
&crate::core::authority::grants::GrantQuery {
|
||||
thread_id: &thread_id,
|
||||
workspace_id: &grant_workspace_id,
|
||||
origin: crate::core::authority::grants::GrantOrigin::Interactive,
|
||||
posture: grant_posture,
|
||||
tool_class: &grant_class,
|
||||
exact_digest: &approval_key,
|
||||
grouping_key: &approval_grouping_key,
|
||||
argv: &[],
|
||||
net_host: None,
|
||||
},
|
||||
);
|
||||
let (grant, deny_grant) = match grant_verdict {
|
||||
crate::core::authority::grants::GrantVerdict::Allow(grant) => {
|
||||
(Some(grant), None)
|
||||
}
|
||||
crate::core::authority::grants::GrantVerdict::Deny(grant) => {
|
||||
(None, Some(grant))
|
||||
}
|
||||
crate::core::authority::grants::GrantVerdict::NoMatch => (None, None),
|
||||
};
|
||||
// This resolver reads the exact live approval posture only;
|
||||
// Core already owns mode and shell admission for this call.
|
||||
let approval_authority = TurnAuthority::from_effective_fields(
|
||||
@@ -17609,7 +17682,7 @@ impl RuntimeThreadManager {
|
||||
let disposition = resolve_approval_request_disposition(
|
||||
&approval_authority,
|
||||
grant.is_some(),
|
||||
false, // Runtime has grants, but no session-denial cache.
|
||||
deny_grant.is_some(),
|
||||
approval_force_prompt,
|
||||
// Rust mints this namespace; tool text cannot claim origin.
|
||||
approval_key.starts_with("extcall:ext:"),
|
||||
@@ -17690,13 +17763,30 @@ impl RuntimeThreadManager {
|
||||
"remember": false,
|
||||
"auto": true,
|
||||
"posture": posture,
|
||||
"grant_id": deny_grant.as_ref().map(|grant| grant.grant_id.clone()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let _ = engine
|
||||
.deny_tool_call_by(id, crate::approval_log::ApprovalDecider::Posture)
|
||||
.await;
|
||||
let _ = match deny_grant {
|
||||
Some(grant) => {
|
||||
engine
|
||||
.deny_tool_call_by_grant(
|
||||
id,
|
||||
crate::approval_log::ApprovalDecider::SessionRule,
|
||||
grant,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
engine
|
||||
.deny_tool_call_by(
|
||||
id,
|
||||
crate::approval_log::ApprovalDecider::Posture,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17705,6 +17795,7 @@ impl RuntimeThreadManager {
|
||||
// posture (E1). A forced prompt is never pre-answered.
|
||||
if disposition == ApprovalRequestDisposition::AutoApprove
|
||||
&& let Some(grant) = grant
|
||||
&& self.grants.record_use(&grant.grant_id).is_ok()
|
||||
{
|
||||
let approval_id = Self::mint_approval_id();
|
||||
self.emit_event(
|
||||
@@ -17740,9 +17831,10 @@ impl RuntimeThreadManager {
|
||||
.await
|
||||
.ok();
|
||||
let _ = engine
|
||||
.approve_tool_call_by(
|
||||
.approve_tool_call_by_grant(
|
||||
id,
|
||||
crate::approval_log::ApprovalDecider::SessionRule,
|
||||
grant,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
|
||||
@@ -17072,12 +17072,6 @@ async fn archiving_or_deleting_a_thread_ends_its_session_grants() -> Result<()>
|
||||
)
|
||||
.await?;
|
||||
assert!(manager.approval_grants_for_thread(&archived.id).is_empty());
|
||||
assert!(
|
||||
manager
|
||||
.session_grant_for(&archived.id, "web:web.run:search_query")
|
||||
.is_none(),
|
||||
"the next matching call on an archived thread prompts again"
|
||||
);
|
||||
let events = manager.events_since(&archived.id, None)?;
|
||||
assert!(events.iter().any(|event| {
|
||||
event.event == "approval.grant_revoked"
|
||||
@@ -17099,7 +17093,7 @@ async fn archiving_or_deleting_a_thread_ends_its_session_grants() -> Result<()>
|
||||
.is_none(),
|
||||
"an archived thread records no new grant"
|
||||
);
|
||||
assert!(manager.approval_grants.lock().get(&archived.id).is_none());
|
||||
assert!(manager.approval_grants_for_thread(&archived.id).is_empty());
|
||||
// Unarchiving does not bring the grant back.
|
||||
manager
|
||||
.update_thread(
|
||||
@@ -17116,7 +17110,7 @@ async fn archiving_or_deleting_a_thread_ends_its_session_grants() -> Result<()>
|
||||
|
||||
// Deleting a thread drops its grants with it.
|
||||
manager.discard_empty_thread(&kept.id).await?;
|
||||
assert!(manager.approval_grants.lock().get(&kept.id).is_none());
|
||||
assert!(manager.approval_grants_for_thread(&kept.id).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user