Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

165 changes: 118 additions & 47 deletions crates/ironclaw_approvals/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use ironclaw_filesystem::{
use ironclaw_host_api::{
Action, ApprovalRequestId, CapabilityGrant, CapabilityGrantId, CapabilityId, GrantConstraints,
HostApiError, PermissionMode, Principal, ProjectId, ResourceScope, ScopedPath, SystemServiceId,
TenantId, ThreadId, UserId, sha256_digest_token,
TenantId, UserId, sha256_digest_token,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
Expand Down Expand Up @@ -40,8 +40,6 @@ pub fn persistent_approval_grant_issuer() -> Principal {

#[derive(Debug, Error)]
pub enum PersistentApprovalPolicyError {
#[error("persistent approval scope must include project_id or thread_id")]
UnsupportedScope,
#[error("unknown persistent approval policy")]
UnknownPolicy,
#[error("persistent approval policy changed concurrently")]
Expand Down Expand Up @@ -95,28 +93,16 @@ pub struct PersistentApprovalScope {
pub user_id: UserId,
pub agent_id: Option<ironclaw_host_api::AgentId>,
pub project_id: Option<ProjectId>,
pub thread_id: Option<ThreadId>,
}

impl PersistentApprovalScope {
pub fn from_resource_scope(
scope: &ResourceScope,
) -> Result<Self, PersistentApprovalPolicyError> {
if scope.project_id.is_none() && scope.thread_id.is_none() {
return Err(PersistentApprovalPolicyError::UnsupportedScope);
}
let thread_id = if scope.project_id.is_some() {
None
} else {
scope.thread_id.clone()
};
Ok(Self {
pub fn from_resource_scope(scope: &ResourceScope) -> Self {
Self {
tenant_id: scope.tenant_id.clone(),
user_id: scope.user_id.clone(),
agent_id: scope.agent_id.clone(),
project_id: scope.project_id.clone(),
thread_id,
})
}
}
}

Expand All @@ -134,13 +120,13 @@ impl PersistentApprovalPolicyKey {
action: PersistentApprovalAction,
capability_id: CapabilityId,
grantee: Principal,
) -> Result<Self, PersistentApprovalPolicyError> {
Ok(Self {
scope: PersistentApprovalScope::from_resource_scope(scope)?,
) -> Self {
Self {
scope: PersistentApprovalScope::from_resource_scope(scope),
Comment thread
henrypark133 marked this conversation as resolved.
action,
capability_id,
grantee,
})
}
}
}

Expand Down Expand Up @@ -243,7 +229,7 @@ impl PersistentApprovalPolicyStore for InMemoryPersistentApprovalPolicyStore {
input.action,
input.capability_id,
input.grantee,
)?;
);
let mut policies = self
.policies
.write()
Expand Down Expand Up @@ -363,7 +349,7 @@ where
input.action,
input.capability_id,
input.grantee,
)?;
);
let path = self.cached_policy_path(&key)?;
let lock = self.mutation_lock(&key);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium - No contention test for collapsed threadless filesystem writes.

Removing thread_id from PersistentApprovalPolicyKey makes concurrent always-allow writes from different threads for the same user/agent/capability contend on one filesystem policy path and mutation lock. Adjacent tests cover sequential reuse, but none spawn concurrent allow calls that collapse to the same key and verify both succeed without CAS loss.

Fix: Add a filesystem_policy_store_serializes_concurrent_threadless_allows test that runs two concurrent allow calls from different thread ids for the same user/agent/capability and verifies the surviving policy remains usable.

let _guard = lock.lock().await;
Expand Down Expand Up @@ -480,7 +466,17 @@ where
{
let path = self.cached_policy_path(key)?;
let scope = resource_scope_for_policy_key(key);
let Some(versioned) = self.filesystem.get(&scope, &path).await? else {
self.lookup_versioned_at(key, &scope, &path).await
}

async fn lookup_versioned_at(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low - Inline the single-use versioned lookup helper.

lookup_versioned_at is a new one-call helper that only forwards the already-computed scope/path into filesystem.get and deserialize_versioned_policy. After removing the legacy compatibility lookup, this extra hop no longer hides variation or deletes branching, so readers have to jump between two methods to follow the only filesystem lookup path.

Fix: Move the filesystem.get(scope, path) and deserialize_versioned_policy body back into lookup_versioned, then delete lookup_versioned_at.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low - Delete the one-call lookup_versioned_at helper.

After removing the legacy compatibility lookup, lookup_versioned_at has only one caller and just forwards filesystem.get plus deserialize_versioned_policy. Readers now have to follow an extra private method to understand the normal lookup path, but the helper no longer hides any variation.

Fix: Inline the filesystem get and deserialize_versioned_policy call back into lookup_versioned, then remove lookup_versioned_at.

&self,
key: &PersistentApprovalPolicyKey,
scope: &ResourceScope,
path: &ScopedPath,
) -> Result<Option<(PersistentApprovalPolicy, RecordVersion)>, PersistentApprovalPolicyError>
{
let Some(versioned) = self.filesystem.get(scope, path).await? else {
return Ok(None);
};
deserialize_versioned_policy(key, versioned)
Expand Down Expand Up @@ -597,8 +593,6 @@ fn within_tenant_scope(scope: &PersistentApprovalScope) -> String {
}
if let Some(project_id) = &scope.project_id {
segments.push(format!("projects/{project_id}"));
} else if let Some(thread_id) = &scope.thread_id {
segments.push(format!("threads/{thread_id}"));
}
if segments.is_empty() {
"scope".to_string()
Expand Down Expand Up @@ -626,7 +620,7 @@ fn resource_scope_for_policy_key(key: &PersistentApprovalPolicyKey) -> ResourceS
agent_id: key.scope.agent_id.clone(),
project_id: key.scope.project_id.clone(),
mission_id: None,
thread_id: key.scope.thread_id.clone(),
thread_id: None,
invocation_id: ironclaw_host_api::InvocationId::new(),
}
}
Expand Down Expand Up @@ -660,7 +654,7 @@ mod tests {
use ironclaw_filesystem::{InMemoryBackend, LocalFilesystem, ScopedFilesystem};
use ironclaw_host_api::{
AgentId, EffectKind, GrantConstraints, HostPath, InvocationId, MountAlias, MountGrant,
MountPermissions, MountView, NetworkPolicy, ProjectId, VirtualPath,
MountPermissions, MountView, NetworkPolicy, ProjectId, ThreadId, VirtualPath,
};

use super::*;
Expand Down Expand Up @@ -921,25 +915,113 @@ mod tests {
);
}

#[tokio::test]
async fn policy_scope_prefers_project_over_thread() {
#[test]
fn policy_scope_prefers_project_over_thread() {
// Project is still part of the scope; differing thread ids under the same
// project produce identical keys.
let scope_a = scope(Some("project-a"), Some("thread-a"));
let scope_b = scope(Some("project-a"), Some("thread-b"));

let derived_a = PersistentApprovalScope::from_resource_scope(&scope_a);
let derived_b = PersistentApprovalScope::from_resource_scope(&scope_b);

assert_eq!(derived_a, derived_b);
assert_eq!(
derived_a.project_id,
Some(ProjectId::new("project-a").unwrap())
);
}

#[test]
fn project_scoped_policy_key_serialization_is_threadless() {
// Criterion 5 (#4825): persistent approvals intentionally ignore
// thread_id. There is no backward-compatibility read path for old local
// test records, so the key serialization should not retain thread_id.
let key = key_for(&scope(Some("project-a"), Some("thread-ignored")));
let json = serde_json::to_string(&key).expect("serialize policy key");
assert!(
!json.contains("thread_id"),
"persistent approval keys must not serialize thread_id; got {json}"
);

let digest_thread_one =
policy_digest(&key_for(&scope(Some("project-a"), Some("thread-1")))).expect("digest");
let digest_thread_two =
policy_digest(&key_for(&scope(Some("project-a"), Some("thread-2")))).expect("digest");
assert_eq!(digest_thread_one, digest_thread_two);
assert_eq!(
PersistentApprovalScope::from_resource_scope(&scope_a).unwrap(),
PersistentApprovalScope::from_resource_scope(&scope_b).unwrap()
within_tenant_scope(&key.scope),
"agents/agent-a/projects/project-a"
);
}

#[tokio::test]
async fn policy_scope_uses_thread_without_project() {
async fn filesystem_project_scoped_policy_matches_in_new_thread_after_reload() {
// Criterion 5 (#4825): a project-scoped "always allow" applies across

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low - Project-scope reload test is tagged with a stale criterion number.

This test comment calls project-scoped cross-thread lookup criterion 5, while the same branch's criterion 5 is about intentionally not reading old local thread-scoped records. The mismatch makes it harder to navigate which tests cover the migration/non-compatibility decision versus the threadless lookup behavior.

Fix: Rename the comment to describe project-scoped threadless lookup directly, or point it at the criterion that actually covers cross-thread reuse.

// threads in the same project. A fresh store instance looks the policy
// up from a different thread and finds it through the canonical path.
let backend = Arc::new(InMemoryBackend::new());
let scoped = scoped_fs(Arc::clone(&backend), "tenant-a", "alice");
let store = FilesystemPersistentApprovalPolicyStore::new(Arc::clone(&scoped));

let granted = store
.allow(input(scope(Some("project-a"), Some("thread-1"))))
.await
.expect("allow project-scoped policy");

let new_thread_key = key_for(&scope(Some("project-a"), Some("thread-2")));
let reloaded = FilesystemPersistentApprovalPolicyStore::new(scoped)
.lookup(&new_thread_key)
.await
.expect("lookup")
.expect("pre-existing project-scoped policy still matches in a new thread");

assert_eq!(reloaded, granted);
assert!(reloaded.active_grant().is_some());
}

#[test]
fn policy_scope_without_project_is_thread_agnostic() {
let scope_a = scope(None, Some("thread-a"));
let scope_b = scope(None, Some("thread-b"));

let derived_a = PersistentApprovalScope::from_resource_scope(&scope_a);
let derived_b = PersistentApprovalScope::from_resource_scope(&scope_b);

assert_eq!(derived_a, derived_b);
}

#[test]
fn policy_scope_without_project_isolates_users() {
let scope_a = ResourceScope {
user_id: UserId::new("alice").unwrap(),
..scope(None, Some("thread-a"))
};
let scope_b = ResourceScope {
user_id: UserId::new("bob").unwrap(),
..scope(None, Some("thread-a"))
};

assert_ne!(
PersistentApprovalScope::from_resource_scope(&scope_a),
PersistentApprovalScope::from_resource_scope(&scope_b)
);
}

#[test]
fn policy_scope_without_project_isolates_agents() {
let scope_a = ResourceScope {
agent_id: Some(AgentId::new("agent-x").unwrap()),
..scope(None, Some("thread-a"))
};
let scope_b = ResourceScope {
agent_id: Some(AgentId::new("agent-y").unwrap()),
..scope(None, Some("thread-a"))
};

assert_ne!(
PersistentApprovalScope::from_resource_scope(&scope_a).unwrap(),
PersistentApprovalScope::from_resource_scope(&scope_b).unwrap()
PersistentApprovalScope::from_resource_scope(&scope_a),
PersistentApprovalScope::from_resource_scope(&scope_b)
);
}

Expand Down Expand Up @@ -987,16 +1069,6 @@ mod tests {
assert_eq!(grant.issued_by, persistent_approval_grant_issuer());
}

#[tokio::test]
async fn from_resource_scope_errs_without_project_or_thread() {
let scope = scope(None, None);

assert!(matches!(
PersistentApprovalScope::from_resource_scope(&scope),
Err(PersistentApprovalPolicyError::UnsupportedScope)
));
}

fn input(scope: ResourceScope) -> PersistentApprovalPolicyInput {
PersistentApprovalPolicyInput {
scope,
Expand Down Expand Up @@ -1024,7 +1096,6 @@ mod tests {
CapabilityId::new("fixture.echo").unwrap(),
Principal::User(UserId::new("alice").unwrap()),
)
.unwrap()
}

fn scope(project_id: Option<&str>, thread_id: Option<&str>) -> ResourceScope {
Expand Down
20 changes: 6 additions & 14 deletions crates/ironclaw_host_runtime/src/production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,17 +986,7 @@ impl DefaultHostRuntime {
);
return;
}
let scope = match PersistentApprovalScope::from_resource_scope(&context.resource_scope) {
Ok(scope) => scope,
Err(error) => {
tracing::debug!(
capability_id = %capability_id,
error = %error,
"persistent approval lookup skipped for unsupported scope"
);
return;
}
};
let scope = PersistentApprovalScope::from_resource_scope(&context.resource_scope);
Comment thread
henrypark133 marked this conversation as resolved.
let lookup_results = join_all(persistent_approval_grantees(context).into_iter().map(
|grantee| {
let policies = Arc::clone(policies);
Expand Down Expand Up @@ -1557,9 +1547,11 @@ fn persistent_approval_grantees(context: &ironclaw_host_api::ExecutionContext) -
if let Some(mission_id) = &context.mission_id {
grantees.push(Principal::Mission(mission_id.clone()));
}
if let Some(thread_id) = &context.thread_id {
grantees.push(Principal::Thread(thread_id.clone()));
}
// No `Principal::Thread` grantee: persistent approval policies are never

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low - Thread-grantee comment overstates the invariant.

The new comment explains dropping Principal::Thread by saying persistent policies are always written from ApprovalRequest.requested_by as User or Extension, but the same helper still looks up Agent, Project, and Mission grantees just above it. That makes the local invariant harder to trust and can mislead the next change to this grantee list.

Fix: Narrow the comment to the actual reason no Thread lookup is attempted, or remove the User/Extension parenthetical unless those are the only intended durable-policy grantees.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium - Thread-grantee lookup removal lacks caller coverage.

The runtime now explicitly omits Principal::Thread from persistent approval lookup candidates, but the host-runtime contract tests only cover accepted user/extension grantees and rejected tenant grantees. A regression that re-adds thread-grantee replay would not be caught.

Fix: Add default_runtime_does_not_replay_thread_grantee_persistent_policy covering a manually seeded Principal::Thread policy ignored by invoke_capability.

Also flagged by: local-patterns/Low

// written under a thread grantee (the grantee always comes from
// `ApprovalRequest.requested_by`, which is `Principal::User` or
// `Principal::Extension`), so looking one up could never match. Persistent
// approvals are deliberately thread-agnostic (see #4825).
grantees
}

Expand Down
Loading
Loading