fix(desktop): exact-allowlist permission kinds, disclose persistent deny

PermissionDecisionButtons classified actionable options via kind.startsWith("reject"), so an unknown or malformed reject-prefixed kind (e.g. reject_later_v2) still rendered a trusted Deny button whose semantics the UI does not understand. Replace prefix matching with an exact recognized-kind allowlist (allow_once, reject_once, reject_always); any kind outside it fails closed and renders no action. reject_always stays actionable (persistent denial only reduces authority) but its label defaults to "Always deny" when the harness omits one, so a user never silently chooses persistence.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-10 15:45:26 -04:00
co-authored by Will Pfleger
parent 00456e462a
commit ca9acfa36a
2 changed files with 96 additions and 7 deletions
@@ -169,6 +169,67 @@ test("test_unknown_kind_fails_closed_renders_nothing", () => {
);
});
// ---------------------------------------------------------------------------
// Unknown reject_*-prefixed kind — fail closed: exact allowlist, not prefix
// ---------------------------------------------------------------------------
test("test_unknown_reject_prefixed_kind_fails_closed_renders_nothing", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-reject-future", kind: "reject_later_v2" },
]),
}),
);
// A reject-prefixed but unrecognized kind must NOT render a trusted button:
// recognition is an exact allowlist, not a prefix match.
assert.ok(
!html.includes("permission-decision-opt-reject-future"),
"unknown reject_*-prefixed kind must not render an actionable button",
);
assert.ok(
!html.includes("<button"),
"unknown reject_*-prefixed-only card must not render any button element",
);
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"unknown reject_*-prefixed kind must not render the persistent-grant badge",
);
// The outer permission card shell is still rendered.
assert.ok(
html.includes("transcript-permission-item"),
"unknown reject_*-prefixed kind still renders the permission card shell",
);
});
// ---------------------------------------------------------------------------
// reject_always with no label — actionable Deny labelled "Always deny"
// ---------------------------------------------------------------------------
test("test_reject_always_without_label_renders_always_deny_button", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-reject-always", kind: "reject_always" },
]),
}),
);
// Persistent denial stays actionable (fail-safe), but the harness omitted a
// label — the default must disclose persistence, not read generic "Deny".
assert.ok(
html.includes("permission-decision-opt-reject-always"),
"reject_always should render an actionable button",
);
assert.ok(
html.includes("Always deny"),
"reject_always without a label must default to 'Always deny', not 'Deny'",
);
});
// ---------------------------------------------------------------------------
// Mixed options — allow_once + allow_always in same card
// ---------------------------------------------------------------------------
@@ -39,6 +39,33 @@ function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" {
return "cancel";
}
/**
* Exact recognized permission-option kinds the observer feed can act on.
* `allow_once` grants once; `reject_once` / `reject_always` deny (persistent
* denial only reduces authority, so it stays actionable and fail-safe). Any
* kind not in this set — including unrecognized `reject_*` variants — is
* treated as unknown and rendered non-actionable.
*/
const ACTIONABLE_KINDS = new Set([
"allow_once",
"reject_once",
"reject_always",
]);
function isActionableKind(kind: string): boolean {
return ACTIONABLE_KINDS.has(kind);
}
/**
* Default button label when the harness omits one. `reject_always` must read
* "Always deny" so a user never silently chooses persistent denial.
*/
function defaultOptionLabel(kind: string): string {
if (kind === "reject_always") return "Always deny";
if (kind === "reject_once") return "Deny";
return "Allow";
}
/**
* Allow/Deny buttons for an actionable permission card.
* Renders the agent's exact options as labeled buttons; a click sends the
@@ -78,11 +105,12 @@ function PermissionDecisionButtons({
}, [deliveryFailed]);
// Classify each option into an actionable bucket or a non-actionable
// display-only slot. Unknown kinds fail closed: they are not rendered at
// all so the user cannot accidentally make an irreversible choice on an
// option the UI doesn't understand.
const actionableOptions = options.filter(
({ kind }) => kind === "allow_once" || kind.startsWith("reject"),
// display-only slot. Recognition is an EXACT allowlist, never a prefix:
// an unknown kind (including an unrecognized `reject_*` such as
// `reject_later_v2`) fails closed and is not rendered, so the user cannot
// click a trusted-looking button whose semantics this UI doesn't understand.
const actionableOptions = options.filter(({ kind }) =>
isActionableKind(kind),
);
const hasPersistentGrant = options.some(
({ kind }) => kind === "allow_always",
@@ -95,8 +123,8 @@ function PermissionDecisionButtons({
return (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{actionableOptions.map(({ optionId, kind, label }) => {
const isDeny = kind.startsWith("reject");
const displayLabel = label ?? (isDeny ? "Deny" : "Allow");
const isDeny = kind === "reject_once" || kind === "reject_always";
const displayLabel = label ?? defaultOptionLabel(kind);
return (
<button
key={optionId}