blob: 9366c4cbd031be3c4cf2f43f482c6fdce1399a4a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::records::{invalid, read};
use crate::{EventLog, StoreError};
use maka_agent_facts::event::Invocation;
use maka_agent_facts::interaction::InteractionOutcome;
use maka_agent_facts::interaction::InteractionRecord;
use maka_agent_facts::interaction::InteractionRequest;
use maka_agent_facts::interaction::entity_id;
use maka_sandbox::grant::{Decision, Permissions, Scope};
use sqlx::Connection;
/// A projection of an immutable approval outcome, never another grant ledger.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PermissionGrant {
pub request_id: String,
pub permissions: Permissions,
pub scope: Scope,
}
pub(super) async fn require_revision(
connection: &mut sqlx::SqliteConnection,
record: &InteractionRecord,
) -> Result<(), StoreError> {
let revision = match &record.request {
InteractionRequest::Permissions { base_revision, .. }
| InteractionRequest::ClientCapability {
base_revision: Some(base_revision),
..
} => Some(*base_revision),
_ => None,
};
if let Some(revision) = revision
&& boundary_revision(connection, &record.session_id).await? != Some(revision)
{
return Err(invalid("permission request has a stale Session boundary"));
}
Ok(())
}
pub(crate) async fn boundary_revision(
connection: &mut sqlx::SqliteConnection,
session: &str,
) -> Result<Option<u64>, StoreError> {
let revision: Option<Option<i64>> = sqlx::query_scalar(
"SELECT json_extract(configuration, '$.boundary_revision') FROM session_control WHERE id = ? AND archived = 0",
).bind(session).fetch_optional(connection).await?;
revision
.flatten()
.map(|revision| {
u64::try_from(revision)
.ok()
.filter(|revision| *revision <= maka_agent_facts::interaction::MAX_SAFE_INTEGER)
.ok_or_else(|| invalid("invalid Session permission boundary revision"))
})
.transpose()
}
impl EventLog {
/// Caller must separately admit this exact effect. A once grant belongs to
/// its original Run/tool-use identity, not every operation with equal args.
/// Session policy changes invalidate all approvals from the previous basis.
/// Without a tool-use identity, only Turn and Session grants are visible.
pub async fn permission_grants(
&self,
invocation: &Invocation,
tool_use_id: Option<&str>,
base_revision: u64,
) -> Result<Vec<PermissionGrant>, StoreError> {
self.validate_root()?;
for id in [
&invocation.session_id,
&invocation.turn_id,
&invocation.run_id,
] {
entity_id(id).map_err(invalid)?;
}
if tool_use_id.is_some_and(|id| id.is_empty())
|| base_revision > maka_agent_facts::interaction::MAX_SAFE_INTEGER
{
return Err(invalid("invalid permission observation identity"));
}
let invocation = invocation.clone();
let tool_use_id = tool_use_id.map(str::to_owned);
self.connection.run(move |connection| Box::pin(async move {
let mut tx = connection.begin().await?;
if boundary_revision(&mut tx, &invocation.session_id).await? != Some(base_revision) {
return Ok(Vec::new());
}
let ids: Vec<String> = sqlx::query_scalar(
"SELECT request.request_id FROM interaction_requests AS request
JOIN interaction_outcomes AS outcome ON outcome.request_id = request.request_id
WHERE request.session_id = ?
AND json_extract(request.record_json, '$.request.kind') = 'permissions'
AND json_extract(request.record_json, '$.request.baseRevision') = ?
AND json_extract(outcome.outcome_json, '$.kind') = 'permissions_decision'
AND json_extract(outcome.outcome_json, '$.decision.decision') = 'allow'
AND (json_extract(outcome.outcome_json, '$.decision.scope') = 'session'
OR (json_extract(request.record_json, '$.turnId') = ?
AND (json_extract(outcome.outcome_json, '$.decision.scope') = 'turn'
OR (json_extract(outcome.outcome_json, '$.decision.scope') = 'once'
AND json_extract(request.record_json, '$.runId') = ?
AND json_extract(request.record_json, '$.request.toolUseId') = ?))))
ORDER BY request.created_at, request.request_id",
).bind(&invocation.session_id).bind(base_revision as i64).bind(&invocation.turn_id)
.bind(&invocation.run_id).bind(&tool_use_id).fetch_all(&mut *tx).await?;
let mut grants = Vec::with_capacity(ids.len());
for id in ids {
let record = read(&mut tx, &id).await?.ok_or_else(|| invalid("permission outcome disappeared"))?;
let Some(InteractionOutcome::PermissionsDecision { decision: Decision::Allow { permissions, scope }, .. }) = record.outcome else {
return Err(invalid("permission outcome changed kind"));
};
grants.push(PermissionGrant { request_id: id, permissions, scope });
}
tx.commit().await?;
Ok(grants)
})).await
}
}
/// Called only inside the owning metadata CAS. Existing first-writer outcomes
/// stay immutable; the new revision makes those earlier grants inapplicable.
pub(crate) async fn close_previous_boundary(
connection: &mut sqlx::SqliteConnection,
session: &str,
previous: u64,
now: u64,
) -> Result<usize, StoreError> {
let current = boundary_revision(connection, session)
.await?
.ok_or_else(|| invalid("Session permission boundary disappeared"))?;
if current == previous {
return Ok(0);
}
if current <= previous {
return Err(invalid("Session permission boundary must advance"));
}
let closure = InteractionOutcome::Closure {
reason: maka_agent_facts::interaction::ClosureReason::ProducerCancelled,
committed_at: now,
};
let mut closed = 0;
for record in super::pending(connection, session).await? {
if matches!(record.request,
InteractionRequest::Permissions { base_revision, .. }
| InteractionRequest::ClientCapability { base_revision: Some(base_revision), .. }
if base_revision == previous)
{
closure
.validate_for_request(&record.request)
.map_err(invalid)?;
sqlx::query("INSERT INTO interaction_outcomes VALUES (?, ?)")
.bind(&record.request_id)
.bind(super::records::encode(&closure)?)
.execute(&mut *connection)
.await?;
closed += 1;
}
}
Ok(closed)
}