Skip to content

Commit 73e94f2

Browse files
committed
fix(acp): constrain owner permission choices
Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com>
1 parent eeaedd9 commit 73e94f2

10 files changed

Lines changed: 723 additions & 67 deletions

File tree

crates/buzz-acp/src/acp.rs

Lines changed: 315 additions & 28 deletions
Large diffs are not rendered by default.

crates/buzz-acp/src/lib.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,8 @@ fn handle_switch_model_control(
12661266

12671267
/// Handle a `permission_decision` control frame.
12681268
///
1269-
/// Extracts `channelId`, `requestNonce`, and `optionId` from the payload and
1269+
/// Extracts `channelId`, `requestNonce`, and a selected/cancelled outcome from
1270+
/// the payload and
12701271
/// delivers a [`crate::acp::PermissionDecision`] to the in-flight read loop
12711272
/// via the per-task `permission_decision_tx` mpsc channel.
12721273
///
@@ -1296,18 +1297,48 @@ fn handle_permission_decision_control(
12961297
return;
12971298
};
12981299

1299-
let Some(option_id) = payload
1300-
.get("optionId")
1301-
.and_then(|v| v.as_str())
1302-
.filter(|s| !s.is_empty())
1303-
else {
1304-
tracing::warn!("observer permission_decision control frame missing optionId");
1305-
return;
1300+
let selected_outcome = || {
1301+
payload
1302+
.get("optionId")
1303+
.and_then(|value| value.as_str())
1304+
.filter(|value| !value.is_empty())
1305+
.map(
1306+
|option_id| crate::acp::PermissionDecisionOutcome::Selected {
1307+
option_id: option_id.to_string(),
1308+
},
1309+
)
1310+
};
1311+
let outcome = match payload.get("outcome") {
1312+
Some(serde_json::Value::String(value)) if value == "cancelled" => {
1313+
crate::acp::PermissionDecisionOutcome::Cancelled
1314+
}
1315+
Some(serde_json::Value::String(value)) if value == "selected" => {
1316+
let Some(outcome) = selected_outcome() else {
1317+
tracing::warn!("observer permission_decision selected outcome missing optionId");
1318+
return;
1319+
};
1320+
outcome
1321+
}
1322+
None => {
1323+
let Some(outcome) = selected_outcome() else {
1324+
tracing::warn!("observer permission_decision selected outcome missing optionId");
1325+
return;
1326+
};
1327+
outcome
1328+
}
1329+
Some(other) => {
1330+
tracing::warn!(outcome = %other, "observer permission_decision has unknown outcome");
1331+
return;
1332+
}
13061333
};
13071334

13081335
let decision = crate::acp::PermissionDecision {
13091336
request_nonce: request_nonce.to_string(),
1310-
option_id: option_id.to_string(),
1337+
outcome: outcome.clone(),
1338+
};
1339+
let outcome_name = match &outcome {
1340+
crate::acp::PermissionDecisionOutcome::Selected { .. } => "selected",
1341+
crate::acp::PermissionDecisionOutcome::Cancelled => "cancelled",
13111342
};
13121343

13131344
// Find the in-flight task for this channel and deliver via its mpsc.
@@ -1323,7 +1354,7 @@ fn handle_permission_decision_control(
13231354
tracing::info!(
13241355
channel = %channel_id,
13251356
nonce = %request_nonce,
1326-
option_id = %option_id,
1357+
outcome = outcome_name,
13271358
"permission_decision delivered to read loop"
13281359
);
13291360
"sent"
@@ -1372,7 +1403,7 @@ fn handle_permission_decision_control(
13721403
"type": "permission_decision",
13731404
"status": status,
13741405
"requestNonce": request_nonce,
1375-
"optionId": option_id,
1406+
"outcome": outcome_name,
13761407
}),
13771408
);
13781409
}
@@ -4884,6 +4915,62 @@ mod owner_control_command_tests {
48844915
ControlSignal::Rotate
48854916
));
48864917
}
4918+
4919+
#[tokio::test]
4920+
async fn permission_control_delivers_cancel_and_legacy_selection() {
4921+
let mut pool = AgentPool::from_slots(vec![]);
4922+
let channel_id = Uuid::new_v4();
4923+
let (decision_tx, mut decision_rx) = tokio::sync::mpsc::channel(1);
4924+
4925+
let task = pool.join_set.spawn(std::future::pending::<()>());
4926+
pool.task_map_mut().insert(
4927+
task.id(),
4928+
pool::TaskMeta {
4929+
agent_index: 0,
4930+
channel_id: Some(channel_id),
4931+
turn_id: "permission-turn".to_string(),
4932+
recoverable_batch: None,
4933+
control_tx: None,
4934+
steer_tx: None,
4935+
permission_decision_tx: Some(decision_tx),
4936+
},
4937+
);
4938+
4939+
handle_permission_decision_control(
4940+
&serde_json::json!({
4941+
"type": "permission_decision",
4942+
"channelId": channel_id,
4943+
"requestNonce": "cancel-nonce",
4944+
"outcome": "cancelled"
4945+
}),
4946+
&mut pool,
4947+
None,
4948+
);
4949+
let cancelled = decision_rx.recv().await.expect("cancel decision");
4950+
assert_eq!(cancelled.request_nonce, "cancel-nonce");
4951+
assert!(matches!(
4952+
cancelled.outcome,
4953+
crate::acp::PermissionDecisionOutcome::Cancelled
4954+
));
4955+
4956+
handle_permission_decision_control(
4957+
&serde_json::json!({
4958+
"type": "permission_decision",
4959+
"channelId": channel_id,
4960+
"requestNonce": "legacy-nonce",
4961+
"optionId": "allow-once"
4962+
}),
4963+
&mut pool,
4964+
None,
4965+
);
4966+
let selected = decision_rx.recv().await.expect("legacy selected decision");
4967+
assert_eq!(selected.request_nonce, "legacy-nonce");
4968+
assert!(matches!(
4969+
selected.outcome,
4970+
crate::acp::PermissionDecisionOutcome::Selected { option_id }
4971+
if option_id == "allow-once"
4972+
));
4973+
}
48874974
}
48884975

48894976
#[cfg(test)]
@@ -8049,6 +8136,7 @@ mod observer_payload_trim_tests {
80498136
event.authorization = Some(crate::observer::AuthorizationEnvelope {
80508137
request_nonce: "test-nonce".to_string(),
80518138
actionable: true,
8139+
can_cancel: Some(true),
80528140
reason: None,
80538141
});
80548142

crates/buzz-acp/src/observer.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ pub struct AuthorizationEnvelope {
4444
/// `true` when the owner can take action (policy=ask, preflight passed,
4545
/// owner/observer available). `false` for auto-deny / fail-closed paths.
4646
pub actionable: bool,
47+
/// `true` when this harness accepts an owner `cancelled` control outcome.
48+
/// Omitted for older/non-actionable envelopes so Desktop never infers it.
49+
#[serde(skip_serializing_if = "Option::is_none")]
50+
pub can_cancel: Option<bool>,
4751
/// Human-readable reason when `actionable` is `false`.
4852
#[serde(skip_serializing_if = "Option::is_none")]
4953
pub reason: Option<String>,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import React from "react";
5+
import { renderToStaticMarkup } from "react-dom/server";
6+
7+
import { LifecycleActivity } from "./LifecycleActivity.tsx";
8+
9+
function renderPermission(options, { canCancel = false } = {}) {
10+
return renderToStaticMarkup(
11+
React.createElement(LifecycleActivity, {
12+
agentAvatarUrl: null,
13+
agentName: "Agent",
14+
agentPubkey: "agent-pubkey",
15+
item: {
16+
id: "permission:test",
17+
type: "lifecycle",
18+
title: "Permission requested",
19+
text: "Run the requested tool",
20+
timestamp: "2026-08-08T12:00:00.000Z",
21+
renderClass: "permission",
22+
actionable: true,
23+
requestNonce: "nonce",
24+
canCancelPermission: canCancel,
25+
channelId: "channel",
26+
options,
27+
},
28+
}),
29+
);
30+
}
31+
32+
test("permission card renders only one-time decisions", () => {
33+
const html = renderPermission([
34+
{ optionId: "allow", kind: "allow_once", label: "Allow once" },
35+
{ optionId: "reject", kind: "reject_once", label: "Reject once" },
36+
{
37+
optionId: "persistent",
38+
kind: "allow_always",
39+
label: "Always allow",
40+
},
41+
{ optionId: "future", kind: "future_scope", label: "Future choice" },
42+
]);
43+
44+
assert.match(html, /permission-decision-allow/);
45+
assert.match(html, /permission-decision-reject/);
46+
assert.doesNotMatch(html, /permission-decision-persistent/);
47+
assert.doesNotMatch(html, /permission-decision-future/);
48+
assert.doesNotMatch(html, /Always allow/);
49+
assert.doesNotMatch(html, /permission-decision-cancel/);
50+
});
51+
52+
test("new harness allow-only permission card includes an immediate Cancel action", () => {
53+
const html = renderPermission(
54+
[{ optionId: "allow", kind: "allow_once", label: "Allow once" }],
55+
{ canCancel: true },
56+
);
57+
58+
assert.match(html, /permission-decision-allow/);
59+
assert.match(html, /permission-decision-cancel/);
60+
assert.match(html, />Cancel</);
61+
});
62+
63+
test("legacy allow-only permission card does not show an unsupported Cancel action", () => {
64+
const html = renderPermission([
65+
{ optionId: "allow", kind: "allow_once", label: "Allow once" },
66+
]);
67+
68+
assert.match(html, /permission-decision-allow/);
69+
assert.doesNotMatch(html, /permission-decision-cancel/);
70+
});
71+
72+
test("permission card renders no action for persistent or unknown choices", () => {
73+
const html = renderPermission([
74+
{ optionId: "persistent", kind: "allow_always" },
75+
{ optionId: "future", kind: "future_scope" },
76+
]);
77+
78+
assert.doesNotMatch(html, /permission-decision-/);
79+
assert.doesNotMatch(html, />Allow</);
80+
});

desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
22
import * as React from "react";
33

4-
import { sendPermissionDecision } from "@/shared/api/agentControl";
4+
import {
5+
cancelPermissionRequest,
6+
sendPermissionDecision,
7+
} from "@/shared/api/agentControl";
58
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
69
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
710
import { ToolActivity } from "./ToolActivity";
@@ -40,9 +43,8 @@ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" {
4043
}
4144

4245
/**
43-
* Allow/Deny buttons for an actionable permission card.
44-
* Renders the agent's exact options as labeled buttons; a click sends the
45-
* `permission_decision` control event (fire-and-forget).
46+
* One-time Allow/Deny buttons for an actionable permission card. Persistent
47+
* and unknown choices are filtered before they reach this component.
4648
*
4749
* On send failure (relay reject or non-`sent` delivery status), buttons are
4850
* re-enabled so the user can retry. The harness's 300 s fail-closed timeout
@@ -53,20 +55,31 @@ function PermissionDecisionButtons({
5355
channelId,
5456
options,
5557
requestNonce,
58+
canCancel,
5659
deliveryFailed,
5760
}: {
5861
agentPubkey: string;
5962
channelId: string;
6063
options: Array<{ optionId: string; kind: string; label?: string }>;
6164
requestNonce: string;
65+
/** Whether this harness explicitly accepts a protocol-level cancellation. */
66+
canCancel: boolean;
6267
/**
6368
* Monotonically increasing failure token from the reducer — incremented on
6469
* every non-`sent` `control_result`. Keying the effect on this number (not a
6570
* boolean) ensures a second failure after a retry also re-enables buttons.
6671
*/
6772
deliveryFailed?: number;
6873
}) {
69-
const [pending, setPending] = React.useState<string | null>(null);
74+
const [pending, setPending] = React.useState<
75+
{ kind: "option"; optionId: string } | { kind: "cancel" } | null
76+
>(null);
77+
const oneTimeOptions = options.filter(
78+
({ kind }) => kind === "allow_once" || kind === "reject_once",
79+
);
80+
const hasRejectOption = oneTimeOptions.some(
81+
({ kind }) => kind === "reject_once",
82+
);
7083

7184
// Re-enable buttons when the reducer signals delivery failure (non-`sent`
7285
// control_result status). The relay send succeeded but the harness couldn't
@@ -77,14 +90,14 @@ function PermissionDecisionButtons({
7790
}
7891
}, [deliveryFailed]);
7992

80-
if (options.length === 0) {
93+
if (oneTimeOptions.length === 0) {
8194
return null;
8295
}
8396

8497
return (
8598
<div className="mt-1.5 flex flex-wrap gap-1.5">
86-
{options.map(({ optionId, kind, label }) => {
87-
const isDeny = kind.startsWith("reject");
99+
{oneTimeOptions.map(({ optionId, kind, label }) => {
100+
const isDeny = kind === "reject_once";
88101
const displayLabel = label ?? (isDeny ? "Deny" : "Allow");
89102
return (
90103
<button
@@ -98,7 +111,7 @@ function PermissionDecisionButtons({
98111
data-testid={`permission-decision-${optionId}`}
99112
disabled={pending !== null}
100113
onClick={() => {
101-
setPending(optionId);
114+
setPending({ kind: "option", optionId });
102115
void sendPermissionDecision(
103116
agentPubkey,
104117
channelId,
@@ -111,10 +124,32 @@ function PermissionDecisionButtons({
111124
});
112125
}}
113126
>
114-
{pending === optionId ? "…" : displayLabel}
127+
{pending?.kind === "option" && pending.optionId === optionId
128+
? "…"
129+
: displayLabel}
115130
</button>
116131
);
117132
})}
133+
{canCancel && !hasRejectOption ? (
134+
<button
135+
type="button"
136+
className="rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50"
137+
data-testid="permission-decision-cancel"
138+
disabled={pending !== null}
139+
onClick={() => {
140+
setPending({ kind: "cancel" });
141+
void cancelPermissionRequest(
142+
agentPubkey,
143+
channelId,
144+
requestNonce,
145+
).catch(() => {
146+
setPending(null);
147+
});
148+
}}
149+
>
150+
{pending?.kind === "cancel" ? "…" : "Cancel"}
151+
</button>
152+
) : null}
118153
</div>
119154
);
120155
}
@@ -171,6 +206,7 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
171206
channelId={props.item.channelId ?? ""}
172207
options={options}
173208
requestNonce={requestNonce}
209+
canCancel={props.item.canCancelPermission ?? false}
174210
deliveryFailed={deliveryFailed}
175211
/>
176212
) : null}

0 commit comments

Comments
 (0)