From f35afa833dea534f29e597cbf41713b2297c55be Mon Sep 17 00:00:00 2001
From: Max Malkin
Date: Tue, 3 Mar 2026 19:36:21 -0700
Subject: [PATCH] implement editable capabilities on approval page
---
crates/registry/src/db/queries.rs | 5 +-
crates/registry/src/handlers/grants.rs | 4 +
crates/registry/src/services/grant.rs | 43 ++++-
services/approval-ui/src/api.ts | 3 +
.../approval-ui/src/pages/ApprovalPage.tsx | 167 +++++++++++++++++-
5 files changed, 213 insertions(+), 9 deletions(-)
diff --git a/crates/registry/src/db/queries.rs b/crates/registry/src/db/queries.rs
index f88e998..3397c03 100644
--- a/crates/registry/src/db/queries.rs
+++ b/crates/registry/src/db/queries.rs
@@ -347,12 +347,14 @@ pub async fn approve_grant(
approved_by: Uuid,
approval_nonce: &[u8],
approval_signature: &[u8],
+ granted_capabilities: &[Capability],
) -> Result {
let result = sqlx::query(
r#"
UPDATE capability_grants
SET status = 'approved', approved_by = $2, approval_nonce = $3,
- approval_signature = $4, decided_at = NOW(), updated_at = NOW()
+ approval_signature = $4, granted_capabilities = $5,
+ decided_at = NOW(), updated_at = NOW()
WHERE id = $1 AND status = 'pending'
"#,
)
@@ -360,6 +362,7 @@ pub async fn approve_grant(
.bind(approved_by)
.bind(approval_nonce)
.bind(approval_signature)
+ .bind(sqlx::types::Json(granted_capabilities))
.execute(pool)
.await?;
diff --git a/crates/registry/src/handlers/grants.rs b/crates/registry/src/handlers/grants.rs
index ae4dbf2..cee27d2 100644
--- a/crates/registry/src/handlers/grants.rs
+++ b/crates/registry/src/handlers/grants.rs
@@ -81,6 +81,9 @@ pub struct ApproveGrantRequest {
/// Approval signature (WebAuthn signature).
#[serde(with = "hex_serde")]
pub approval_signature: Vec,
+ /// Optional subset of capabilities to grant. If omitted, all requested capabilities are granted.
+ #[serde(default)]
+ pub granted_capabilities: Option>,
}
/// Request a new grant.
@@ -159,6 +162,7 @@ pub async fn approve_grant(
req.approved_by,
&req.approval_nonce,
&req.approval_signature,
+ req.granted_capabilities,
)
.await?;
diff --git a/crates/registry/src/services/grant.rs b/crates/registry/src/services/grant.rs
index f13f983..f032cdd 100644
--- a/crates/registry/src/services/grant.rs
+++ b/crates/registry/src/services/grant.rs
@@ -101,6 +101,7 @@ impl GrantService {
approved_by: Uuid,
approval_nonce: &[u8],
approval_signature: &[u8],
+ granted_capabilities: Option>,
) -> Result {
// Get the grant first
let grant = self
@@ -118,6 +119,31 @@ impl GrantService {
return Err(RegistryError::GrantExpired);
}
+ // Resolve the capability subset to persist
+ let resolved_capabilities = if let Some(caps) = granted_capabilities {
+ if caps.is_empty() {
+ return Err(RegistryError::InvalidCapability(
+ "must grant at least one capability".to_string(),
+ ));
+ }
+ // Verify every requested cap appears in the original request
+ for cap in &caps {
+ let cap_type = capability_type_key(cap);
+ let in_original = grant
+ .requested_capabilities
+ .iter()
+ .any(|r| capability_type_key(r) == cap_type);
+ if !in_original {
+ return Err(RegistryError::InvalidCapability(format!(
+ "cannot grant capability not in original request: {cap_type}"
+ )));
+ }
+ }
+ caps
+ } else {
+ grant.requested_capabilities.clone()
+ };
+
// Approve in database
let approved = db::approve_grant(
self.db.primary(),
@@ -125,6 +151,7 @@ impl GrantService {
approved_by,
approval_nonce,
approval_signature,
+ &resolved_capabilities,
)
.await?;
@@ -132,11 +159,11 @@ impl GrantService {
return Err(RegistryError::GrantNotPending);
}
- // Return updated grant
- // Note: approval_assertion would need full WebAuthn data to populate
+ // Return updated grant with the approved capability subset
let updated = CapabilityGrant {
status: GrantStatus::Approved,
approved_at: Some(Utc::now()),
+ requested_capabilities: resolved_capabilities,
..grant
};
@@ -225,3 +252,15 @@ impl GrantService {
})
}
}
+
+/// Return a stable string key representing the type and resource of a capability,
+/// used to check whether a requested capability is a subset of the original request.
+fn capability_type_key(cap: &Capability) -> String {
+ match cap {
+ Capability::Read { resource, .. } => format!("read:{resource}"),
+ Capability::Write { resource, .. } => format!("write:{resource}"),
+ Capability::Delete { resource, .. } => format!("delete:{resource}"),
+ Capability::Transact { resource, .. } => format!("transact:{resource}"),
+ Capability::Custom { namespace, name, .. } => format!("custom:{namespace}:{name}"),
+ }
+}
diff --git a/services/approval-ui/src/api.ts b/services/approval-ui/src/api.ts
index 5f36f45..a69225d 100644
--- a/services/approval-ui/src/api.ts
+++ b/services/approval-ui/src/api.ts
@@ -7,6 +7,7 @@ import type {
AuditEvent,
ApprovalAssertion,
ApiError,
+ Capability,
} from "./types";
const REGISTRY_URL = "http://localhost:8080";
@@ -114,6 +115,7 @@ export async function approveGrant(
approvedBy: string,
approvalNonce: string,
approvalSignature: string,
+ grantedCapabilities: Capability[],
): Promise {
await request(`/v1/grants/${grantId}/approve`, {
method: "POST",
@@ -121,6 +123,7 @@ export async function approveGrant(
approved_by: approvedBy,
approval_nonce: approvalNonce,
approval_signature: approvalSignature,
+ granted_capabilities: grantedCapabilities,
}),
});
}
diff --git a/services/approval-ui/src/pages/ApprovalPage.tsx b/services/approval-ui/src/pages/ApprovalPage.tsx
index 6299592..ccb2b98 100644
--- a/services/approval-ui/src/pages/ApprovalPage.tsx
+++ b/services/approval-ui/src/pages/ApprovalPage.tsx
@@ -24,6 +24,12 @@ export function ApprovalPage() {
const { navigate } = useRouter();
const [state, setState] = useState({ type: "loading" });
const [denyReason, setDenyReason] = useState("");
+ const [enabledIndices, setEnabledIndices] = useState>(
+ new Set(),
+ );
+ const [pendingRemoveIndex, setPendingRemoveIndex] = useState(
+ null,
+ );
useEffect(() => {
loadGrant();
@@ -55,6 +61,11 @@ export function ApprovalPage() {
});
return;
}
+ // Initialize all capabilities as enabled
+ setEnabledIndices(
+ new Set(grant.requested_capabilities.map((_, i) => i)),
+ );
+ setPendingRemoveIndex(null);
setState({ type: "loaded", grant });
} catch (err) {
setState({
@@ -68,8 +79,38 @@ export function ApprovalPage() {
}
}
+ function handleRemove(grant: GrantRequest, index: number) {
+ const cap = grant.requested_capabilities[index];
+ if (cap === undefined) return;
+ if (requiresTwoStep(cap)) {
+ setPendingRemoveIndex(index);
+ } else {
+ setEnabledIndices((prev) => {
+ const next = new Set(prev);
+ next.delete(index);
+ return next;
+ });
+ }
+ }
+
+ function handleConfirmRemove(index: number) {
+ setEnabledIndices((prev) => {
+ const next = new Set(prev);
+ next.delete(index);
+ return next;
+ });
+ setPendingRemoveIndex(null);
+ }
+
+ function handleRestore(index: number) {
+ setEnabledIndices((prev) => new Set([...prev, index]));
+ }
+
function handleApproveClick(grant: GrantRequest) {
- const summary = getCapabilitySummary(grant.requested_capabilities);
+ const granted = grant.requested_capabilities.filter((_, i) =>
+ enabledIndices.has(i),
+ );
+ const summary = getCapabilitySummary(granted);
if (summary.hasHighRisk) {
setState({ type: "confirming", grant, step: 1 });
} else {
@@ -98,11 +139,16 @@ export function ApprovalPage() {
b.toString(16).padStart(2, "0"),
).join("");
+ const grantedCapabilities = grant.requested_capabilities.filter(
+ (_, i) => enabledIndices.has(i),
+ );
+
await approveGrant(
grant.grant_id,
grant.human_principal_id,
nonce,
signature,
+ grantedCapabilities,
);
setState({ type: "success", action: "approved" });
} catch (err) {
@@ -285,7 +331,11 @@ export function ApprovalPage() {
const grant = state.grant;
const isConfirming = state.type === "confirming";
const confirmStep = isConfirming ? state.step : 0;
- const summary = getCapabilitySummary(grant.requested_capabilities);
+ const grantedCapabilities = grant.requested_capabilities.filter((_, i) =>
+ enabledIndices.has(i),
+ );
+ const summary = getCapabilitySummary(grantedCapabilities);
+ const allRemoved = grantedCapabilities.length === 0;
return (
@@ -327,9 +377,27 @@ export function ApprovalPage() {
{grant.requested_capabilities.map((cap, idx) => (
-
+ handleRemove(grant, idx)}
+ onRestore={() => handleRestore(idx)}
+ onConfirmRemove={() =>
+ handleConfirmRemove(idx)
+ }
+ onCancelRemove={() =>
+ setPendingRemoveIndex(null)
+ }
+ />
))}
+ {allRemoved && (
+
+ All permissions removed — use Deny instead
+
+ )}
{/* Behavioral constraints */}
@@ -385,7 +453,12 @@ export function ApprovalPage() {
@@ -444,7 +517,7 @@ export function ApprovalPage() {
You are granting access to:
- {grant.requested_capabilities
+ {grantedCapabilities
.filter(requiresTwoStep)
.map((cap, idx) => (
void;
+ onRestore: () => void;
+ onConfirmRemove: () => void;
+ onCancelRemove: () => void;
+}) {
const risk = getCapabilityRiskLevel(capability);
const needsTwoStep = requiresTwoStep(capability);
@@ -560,6 +649,66 @@ function CapabilityRow({ capability }: { capability: Capability }) {
high: "bg-red",
};
+ if (!enabled) {
+ return (
+
+
+
+ {capabilityToHumanReadable(capability)}
+
+
+ REMOVED
+
+
+
+ );
+ }
+
+ if (isPendingRemove) {
+ return (
+
+
+
+
+ {capabilityToHumanReadable(capability)}
+
+ {needsTwoStep && (
+
+ 2-STEP
+
+ )}
+
+
+
+ Remove this permission? The agent will be denied this
+ capability.
+
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -571,6 +720,12 @@ function CapabilityRow({ capability }: { capability: Capability }) {
2-STEP
)}
+
);
}