diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d813d7a..03b22a3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -432,6 +432,11 @@ and versions follow [Semantic Versioning](https://semver.org/). tell you when something happens, the plan no longer reports on every check, and its check no longer looks at only the page's heading or first line (so an event added at the bottom is noticed). If the plan does either, the planner is told why in plain words and writes it again. Asking for "a report every hour" or "the headline" still works. +- **A free "Subscribe" button no longer reads as a purchase.** When Sentient asks before clicking a newsletter, + follow or "notify me" button with no price nearby, the card now says it wants to sign you up, instead of "Make a + purchase" with "Yes, buy it". It still asks under your usual rules. A button with a price, a plan, a trial, an + upgrade or "/month" on it or next to it, or a plain "Subscribe" with nothing saying it is free, still reads as a + purchase and always asks. - **Telling Sentient something changed updates the memory.** After "Mira starts school in June", saying "Mira's school now starts in July" replaces June with July instead of being ignored as something Sentient already knew. The same goes for a new place ("Ravi moved from Pune to Delhi"), a new number or a new name. Things that are only about diff --git a/desktop/src/features/chat/ApprovalCard.tsx b/desktop/src/features/chat/ApprovalCard.tsx index 5f8c5d1fa..8ab6e1f88 100644 --- a/desktop/src/features/chat/ApprovalCard.tsx +++ b/desktop/src/features/chat/ApprovalCard.tsx @@ -14,6 +14,7 @@ import { IconTerminal2, IconTrash, IconLock, + IconUserPlus, IconWorldWww, IconX, type Icon @@ -58,6 +59,7 @@ const RISK_ICONS: Record = { 'Home security': IconLock, 'Runs code': IconTerminal2, Purchase: IconCreditCard, + 'Signs up': IconUserPlus, Deletes: IconTrash, 'Posts publicly': IconSend, Submits: IconSend, @@ -75,7 +77,7 @@ const RISK_ICONS: Record = { * reads as a move to the trash, not a loss, and is checked first. */ export function effectiveRisk( - approval: Pick & Pick, 'preview'> + approval: Pick & Pick, 'preview' | 'riskLabel'> ): EffectiveRisk { // a delete for good became its app's trash version (#142): it can come back, so it reads as a change, not a loss if (approval.preview?.kind === 'trash' && approval.risk !== 'exec') diff --git a/desktop/src/features/chat/approvalRisk.ts b/desktop/src/features/chat/approvalRisk.ts index 2428d648c..b09d83f94 100644 --- a/desktop/src/features/chat/approvalRisk.ts +++ b/desktop/src/features/chat/approvalRisk.ts @@ -22,6 +22,7 @@ export type RiskLabel = | 'Home security' | 'Runs code' | 'Purchase' + | 'Signs up' | 'Deletes' | 'Posts publicly' | 'Submits' @@ -105,6 +106,8 @@ export function approvalRisk(approval: { * data and never steers the wording. */ arguments?: Record + /** The engine's own label for the call (§10 `describe_fn`), e.g. "Signs up" for a free newsletter button (#378). */ + riskLabel?: string | null }): RiskWording { // A multi-execute tool's real action lives in its slugs; treat them like tool names. const name = [approval.name, ...actionSlugs(approval.arguments)].join(' ') @@ -114,6 +117,9 @@ export function approvalRisk(approval: { if (home && (approval.risk === 'send' || approval.risk === 'exec')) return { label: 'Home security', verb: home.verb, tone: 'danger', ...(home.caution ? { caution: home.caution } : {}) } if (approval.risk === 'exec') return { label: 'Runs code', verb: 'run code on your computer', tone: 'danger' } + // A free subscribe, follow or "notify me" (#378): the engine saw no price or plan on or near the button. + if (approval.riskLabel === 'Signs up' && approval.risk !== 'read') + return { label: 'Signs up', verb: 'sign you up for something', tone: approval.risk === 'send' ? 'danger' : 'warning' } if (approval.risk === 'send') { if (isEmailTool(name)) return { label: 'Sends an email', verb: 'send an email', tone: 'danger' } diff --git a/desktop/tests/approvalRisk.test.ts b/desktop/tests/approvalRisk.test.ts index 90247f040..dea850688 100644 --- a/desktop/tests/approvalRisk.test.ts +++ b/desktop/tests/approvalRisk.test.ts @@ -177,3 +177,26 @@ test('Home Assistant locks, alarms and garage doors read as home security, with assert.equal(blinds.label, 'Changes things') assert.equal(blinds.caution, undefined) }) + +// #378: a free subscribe the engine labels "Signs up" reads as a sign-up, not a purchase; +// a priced one keeps the purchase wording. +const click = (riskLabel: string) => ({ + risk: 'send' as const, + name: 'browser_click', + reason: 'browser: Click the element with this ref. Buttons that buy, pay, send, post, delete or confirm need approval.', + arguments: { ref: 'e3' }, + riskLabel +}) + +test('a free newsletter button reads as signing up', () => { + const w = approvalRisk(click('Signs up')) + assert.equal(w.label, 'Signs up') + assert.equal(w.verb, 'sign you up for something') + assert.equal(w.tone, 'danger') +}) + +test('a priced subscribe button still reads as a purchase', () => { + const w = approvalRisk(click('Purchase')) + assert.equal(w.label, 'Purchase') + assert.equal(w.verb, 'make a purchase') +}) diff --git a/docs/API.md b/docs/API.md index f06b4ba90..527096d2f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1890,7 +1890,7 @@ Every new tool declares a `Risk`; approvals behave as in section 1. Outside `run_loop`, use `await app.approvals.requires_approval(tool, arguments, ctx) -> (bool, Risk)`; `needs_approval(tool, session_id, risk=None, *, arguments=None, ctx=None)` evaluates a synchronous `risk_fn` when given `arguments` (an async one counts as at least `send` there). -- `approval_request` also carries `risk_label` (e.g. "Purchase", "Deletes", "Posts publicly", "Sends", "Runs code", +- `approval_request` also carries `risk_label` (e.g. "Purchase", "Signs up", "Deletes", "Posts publicly", "Sends", "Runs code", "Changes something") and `target` (short label of what is acted on, e.g. "Place order", or null). They come from the optional `Tool.describe_fn(arguments, ctx) -> {risk_label?, target?}` (sync or async, also `@tool(..., describe_fn=...)`); without it `risk_label` is "Looks something up" / "Changes something" / "Sends" / "Runs code" by effective risk. @@ -2272,6 +2272,13 @@ Every new tool declares a `Risk`; approvals behave as in section 1. pay, place order, checkout, send, post, publish, delete, remove, confirm, subscribe, transfer, or it submits a form with payment fields → `{ok, clicked, url, message, new_tab?, dialogs?}`. A link opening a new tab makes it active. `confirm()` dialogs are accepted only for clicks approved as `send`; alerts are accepted; `dialogs` lists them. + Its approval `risk_label` is "Purchase" for buy, pay, order, checkout and the like. A subscribe, follow, sign up or + "notify me" reads "Signs up" (normal approval rules) only when the element or the text around it (its card or + form) says what free thing it is (newsletter, updates, waitlist, alerts, "free" ...) and shows no price, currency, + billing period ("/month", "per seat", an amount beside "monthly" or "annually"), plan, trial or upgrade. When that text stops early at another offer's button + and holds little besides the button itself, the free word is not enough. Anything else, a bare "Subscribe" + included, stays "Purchase", which always asks; a sign-up labelled "Purchase" is always rated `send`. A "Sign up" + with neither a free nor a money signal keeps its plain "Clicks" label and `write` risk. Defensive check at call time: a send-looking click that approvals did not rate `send` (page changed, or no approval step ran and approvals mode is not `off`) is refused with an explanation. - `browser_type(ref, text, submit=False)` write (`send` when submit presses Enter in a payment form, a form whose button diff --git a/sentient/browser/safety.py b/sentient/browser/safety.py index 95efff027..96e3f38c2 100644 --- a/sentient/browser/safety.py +++ b/sentient/browser/safety.py @@ -9,7 +9,8 @@ Element descriptors are the dicts produced by the snapshot script (``snapshot.py``): ``{role, name, tag, type, value, href, autocomplete, id, name_attr, label, placeholder, -aria_label, title, in_form, form_payment, form_submit_text, is_submit}``. Missing keys are fine. +aria_label, title, in_form, form_payment, form_submit_text, is_submit, near, near_cut}``. ``near`` is the text around the +element (its card or form); ``near_cut`` says it stopped early at another offer. Missing keys are fine. """ from __future__ import annotations @@ -33,7 +34,7 @@ # approval wording: first matching rule names the kind of action for the approval card _LABEL_RULES = [ - (re.compile(r"\b(?:buy|purchase|pay|payment|order|check ?out|donate|subscribe|book now|reserve|transfer|" + (re.compile(r"\b(?:buy|purchase|pay|payment|order|check ?out|donate|book now|reserve|transfer|" r"add money|withdraw)\b", re.IGNORECASE), "Purchase"), (re.compile(r"\b(?:delete|remove|erase|discard|trash|deactivate|close (?:my )?account)\b", re.IGNORECASE), "Deletes"), @@ -41,6 +42,33 @@ (re.compile(r"\b(?:send|submit|confirm|reply)\b", re.IGNORECASE), "Sends"), ] _DEFAULT_LABELS = {"click": "Clicks", "type": "Types into the page", "press": "Presses a key"} +SIGNUP_LABEL = "Signs up" + +# subscribe, follow, notify me: "Signs up" only with a free signal and no money signal on or around it (#378) +_SIGNUP_RE = re.compile( + r"\b(?:subscribe|sign ?up|follow|notify me|get (?:the )?updates|" + r"join (?:the |our )?(?:newsletter|mailing list|wait ?list))\b", + re.IGNORECASE, +) +# anything that says it may cost money: a price or currency, a billing period, a plan, a trial or an upgrade +_PAID_RE = re.compile( + r"[$¢£¥€₹₩₽₺₫₱฿₦₪₴₨]" + r"|\b(?:usd|eur|gbp|inr|jpy|cad|aud|rs|rupees?|dollars?|euros?|pounds?)\b" + r"|/\s*(?:mo|mon|month|yr|year|wk|week|day)s?\b" + r"|\bper\s+(?:month|year|week|day|user|seat)\b|\b(?:a|each|every)\s+(?:month|year|week)\b" + # an amount next to a billing word ("499 monthly", "billed annually at 4,999"); "monthly newsletter" stays free + r"|\d[\d,.]*\D{0,20}?\b(?:monthly|yearly|annually|annual|quarterly|per\s+annum)\b" + r"|\b(?:monthly|yearly|annually|annual|quarterly|per\s+annum)\b\D{0,20}?\d" + r"|\b(?:plans?|trial|upgrade|premium|pricing|prices?|billed|billing|" + r"paid|pay|payment|buy|purchase|checkout|membership|pro)\b", + re.IGNORECASE, +) +# what a free sign-up says about itself; "Signs up" needs one of these, so a bare "Subscribe" stays a purchase +_FREE_RE = re.compile( + r"\b(?:free|news ?letters?|updates?|wait ?list|notify me|notifications?|follow(?:ing)?|digest|alerts?|" + r"mailing list|no spam|inbox|emails?|blog|podcast|channel)\b", + re.IGNORECASE, +) def approval_target(el: dict | None) -> str: @@ -54,10 +82,45 @@ def approval_target(el: dict | None) -> str: return str(el.get("role") or el.get("tag") or "")[:80] -def risk_label_for(text: str, kind: str) -> str: - """'Purchase', 'Deletes', 'Posts publicly', 'Sends', else a neutral label for the action kind.""" - for rx, label in _LABEL_RULES: - if rx.search(text or ""): +def _partial_offer(text: str, near: str, near_cut: bool) -> bool: + """True when the context stopped early and holds little besides the element itself: no whole card was read, + so a price may sit just out of reach.""" + if not near_cut: + return False + rest = (near or "").replace(text, " ") if text else (near or "") + return len(re.sub(r"\W+", "", rest)) < 20 + + +def paid_signup(text: str, near: str = "", near_cut: bool = False) -> bool: + """True when ``text`` is a subscribe, sign up or follow that the approval card calls a 'Purchase'.""" + text = text or "" + return bool(_SIGNUP_RE.search(text)) and not _LABEL_RULES[0][0].search(text) and ( + risk_label_for(text, "click", near, near_cut) == _LABEL_RULES[0][1]) + + +def risk_label_for(text: str, kind: str, near: str = "", near_cut: bool = False) -> str: + """'Purchase', 'Signs up', 'Deletes', 'Posts publicly', 'Sends', else a neutral label for the action kind. + + ``near`` is the text around the element (its card or form). A subscribe, follow or "notify me" reads + 'Signs up' only when the element or ``near`` says what free thing it is (a newsletter, updates, a waitlist, + alerts, "free" ...) and neither shows a price, currency, billing period, plan, trial or upgrade. Anything else + may cost money, so it is 'Purchase', which always asks: a bare "Subscribe" is a purchase. A "Sign up" with + neither a free nor a money signal keeps the plain label it had before (#378). ``near_cut`` says the context stopped + early (at another offer's button); then only a whole card of text can show it is free. A 'Purchase' label on a + sign-up comes with ``send`` risk (:func:`paid_signup`, :func:`click_risk`), so its hard stop can't be skipped.""" + text = text or "" + purchase_rule, *other_rules = _LABEL_RULES + if purchase_rule[0].search(text): + return purchase_rule[1] + if _SIGNUP_RE.search(text): + context = f"{text} {near or ''}" + paid = _PAID_RE.search(context) + if _FREE_RE.search(context) and not paid and not _partial_offer(text, near, near_cut): + return SIGNUP_LABEL + if paid or RISKY_CLICK_RE.search(text): + return purchase_rule[1] + for rx, label in other_rules: + if rx.search(text): return label return _DEFAULT_LABELS.get(kind, "Changes something") @@ -92,6 +155,9 @@ def click_risk(el: dict | None) -> Risk: return Risk.send if el.get("is_submit") and el.get("form_payment"): return Risk.send + # a sign-up the approval card calls a purchase is at least send, so "Purchases always ask" holds (#378) + if paid_signup(approval_target(el), str(el.get("near") or ""), bool(el.get("near_cut"))): + return Risk.send return Risk.write @@ -107,6 +173,9 @@ def type_risk(el: dict | None, submit: bool) -> Risk: label = " ".join(_norm(el.get(k)) for k in ("name", "aria_label", "label", "placeholder", "name_attr", "id")) if _MESSAGE_FIELD_RE.search(label): return Risk.send + context = " ".join([approval_target(el), str(el.get("form_submit_text") or "")]) + if paid_signup(context, str(el.get("near") or ""), bool(el.get("near_cut"))): + return Risk.send return Risk.write diff --git a/sentient/browser/service.py b/sentient/browser/service.py index 79a157fc7..c658cf2bc 100644 --- a/sentient/browser/service.py +++ b/sentient/browser/service.py @@ -1418,7 +1418,9 @@ def describe_for(self, kind: str, arguments: dict | None) -> dict: el = (self._snap or {}).get("refs", {}).get(ref) if ref else None target = safety.approval_target(el) context = " ".join([target, str((el or {}).get("form_submit_text") or "")]) if kind == "type" else target - return {"risk_label": safety.risk_label_for(context, kind), "target": target} + near = str((el or {}).get("near") or "") + cut = bool((el or {}).get("near_cut")) + return {"risk_label": safety.risk_label_for(context, kind, near, cut), "target": target} def _eval_window(self) -> float: return float(self.app.config.tools.approvals.timeout_s) + 120.0 diff --git a/sentient/browser/snapshot.py b/sentient/browser/snapshot.py index 38209e663..b7273de87 100644 --- a/sentient/browser/snapshot.py +++ b/sentient/browser/snapshot.py @@ -133,6 +133,42 @@ const submitText = clip(submits.map(b => b.innerText || b.value || b.getAttribute('aria-label') || '').join(' | '), 120); return { in_form: true, form_payment: payment, form_submit_text: submitText }; } +// The text around a sign-up looking element (its card or form), so a price beside a "Subscribe" button is seen (#378). +// Climbs at most four parents. It stops before a container holding another offer button (the next card on a pricing +// page); in a container with much more text than one card it keeps the text just before and after the element. +const SIGNUP = /\b(subscribe|sign ?up|follow|notify|updates|join|newsletter|wait ?list)\b/i; +const OFFER = /\b(subscribe|sign ?up|follow|notify me|join|buy|purchase|pay|order|check ?out|upgrade|trial|get started|choose|select)\b/i; +const CLICKABLE = 'button, a[href], input[type=submit], input[type=button], input[type=image], [role=button], [role=link]'; +function otherOffer(node, el) { + return Array.from(node.querySelectorAll(CLICKABLE)).some(o => o !== el && !o.contains(el) && !el.contains(o) && + OFFER.test(o.innerText || o.value || o.getAttribute('aria-label') || '')); +} +function windowText(node, el, before) { + const r = document.createRange(); + if (before) { r.setStart(node, 0); r.setEndBefore(el); } else { r.setStartAfter(el); r.setEnd(node, node.childNodes.length); } + return r.toString().replace(/\s+/g, ' ').trim(); +} +function nearText(el, d) { + const own = [d.name, d.aria_label, d.title, d.label, d.value, d.form_submit_text].join(' '); + if (!SIGNUP.test(own)) return { near: '', cut: false }; + // cut: the climb stopped early (another offer button, or a container too big to read), so a price may be out of reach + let near = '', cut = false; + let node = el.parentElement; + for (let i = 0; i < 4 && node && node !== document.documentElement; i++) { + const huge = node === document.body || (node.textContent || '').length > 20000; + if (i > 0 && !huge && otherOffer(node, el)) { cut = true; break; } + const text = (node.textContent || '').length > 4000 ? '' : (node.innerText || '').replace(/\s+/g, ' ').trim(); + if (!text || text.length > 400) { + // a larger card or section: keep the text just before and after the element inside it + if (huge) cut = true; + else near = [windowText(node, el, true).slice(-400), near, windowText(node, el, false).slice(0, 200)].join(' '); + break; + } + near = text; + node = node.parentElement; + } + return { near: clip(near, 700), cut }; +} const els = []; let n = 0; const max = args.maxElements || 300; @@ -168,6 +204,9 @@ d.value = sel ? clip(sel.label || sel.text, 60) : ''; } Object.assign(d, formInfo(el)); + const around = nearText(el, d); + d.near = around.near; + d.near_cut = around.cut; d.focused = document.activeElement === el; els.push(d); } diff --git a/sentient/tools/rules.py b/sentient/tools/rules.py index 1cb9d044f..65110ab08 100644 --- a/sentient/tools/rules.py +++ b/sentient/tools/rules.py @@ -37,7 +37,7 @@ RULE_TITLES = { ALLOW: "Allow", IF_ASKED: "Only if I asked", ASK: "Always ask", HAND_OFF: "Hand it to me", NEVER: "Never", } -# describe_fn label for money leaving the user (browser: buy, pay, order, subscribe, donate, transfer ...) +# describe_fn label for money leaving the user (browser: buy, pay, order, a priced subscribe, donate, transfer ...) PURCHASE_LABEL = "Purchase" # describe_fn labels for a call that opens the user's home (#175, ADR 0028): unlocking a door, turning off the alarm, # opening a garage door, gate or door, or switching something that sounds like one. Like a purchase, it always asks. diff --git a/tests/browser/conftest.py b/tests/browser/conftest.py index 604fb9c3d..3fec6862f 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -49,6 +49,20 @@ "help.html": "Help

Help center

", "download.html": 'DownloadsDownload report', "report.txt": "Sentient browser download test.\n", + "signup.html": """Newsletter
+

Weekly notes

Free, no spam.

+

Pro

All the extras, ₹499/month.

+

Team

$20 per seat
Learn more
+

Studio

""" + "Long notes on every plant we grow, with photos and care steps. " * 8 + """

+

Studio pass, ₹999/month.

Learn more
+
""", + # a priced card whose context stops early: another offer button ("Choose") shares the row with Subscribe + "signup-choose.html": """Team
+

Team plan

₹2,999/month for five seats.

+
+

Insider

₹1,499/month, billed yearly.

+
+
""", } diff --git a/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py new file mode 100644 index 000000000..0f100811e --- /dev/null +++ b/tests/browser/test_signup_label.py @@ -0,0 +1,164 @@ +"""A free sign-up reads "Signs up"; anything that may cost money stays "Purchase" and always asks (#378).""" + +from __future__ import annotations + +import pytest + +from sentient.browser import safety +from sentient.browser import tools as bt +from sentient.browser.service import BrowserService +from sentient.tools.base import Risk +from sentient.tools.rules import PURCHASE_LABEL, is_purchase +from tests.browser.conftest import make_app, make_ctx + +PAID_BUTTON = f"Subscribe {chr(0x2013)} {chr(0x20B9)}499/month" # with an en dash, as sites write it + + +@pytest.mark.parametrize("name", [ + "Subscribe to newsletter", "Follow", "Notify me", "Get updates", "Join the waitlist", "Subscribe to alerts", +]) +def test_free_signup_reads_signs_up(name): + assert safety.risk_label_for(name, "click") == "Signs up" + assert safety.risk_label_for(name, "click", near="Our weekly newsletter. Free, no spam. " + name) == "Signs up" + + +def test_a_free_word_in_a_cut_short_context_is_not_enough(): + # the context stopped at another offer and holds only the button: a price may be just out of reach + assert safety.risk_label_for("Subscribe to the newsletter", "click", "Subscribe to the newsletter", True) == "Purchase" + # a whole free card was read before the stop: fine + near = "The weekly newsletter. Short tips every Sunday. Free, no spam. Subscribe" + assert safety.risk_label_for("Subscribe", "click", near, True) == "Signs up" + + +def test_a_bare_subscribe_needs_a_free_signal_nearby(): + assert safety.risk_label_for("Subscribe", "click") == PURCHASE_LABEL + assert safety.risk_label_for("Subscribe", "click", near="Team plan Subscribe") == PURCHASE_LABEL + near = "The weekly newsletter. Free, no spam. Subscribe" + assert safety.risk_label_for("Subscribe", "click", near=near) == "Signs up" + + +def test_a_bare_sign_up_keeps_its_plain_label_unless_money_shows(): + # "Sign up" was never a risky click; with no free and no money signal it stays an ordinary click, as before + assert safety.risk_label_for("Sign up", "click") == "Clicks" + assert safety.click_risk({"role": "button", "name": "Sign up"}) == Risk.write + assert safety.risk_label_for("Sign up", "click", near="Free forever. Sign up") == "Signs up" + assert safety.risk_label_for("Sign up", "click", near="Pro plan, $9/month. Sign up") == PURCHASE_LABEL + + +@pytest.mark.parametrize("name, near", [ + ("Sign up", "Pro plan, $9/month. Sign up"), + ("Follow", "Upgrade to follow more creators"), + ("Notify me", "Price drops to 20 EUR per month"), + ("Subscribe to the newsletter", ""), # with near_cut below: the price may be out of reach +]) +def test_a_purchase_label_on_a_sign_up_is_always_send(name, near): + """CodeRabbit #382: a 'Purchase' label must come with send risk, or is_purchase would skip its hard stop.""" + el = {"role": "button", "name": name, "near": near or name, "near_cut": not near} + assert safety.risk_label_for(name, "click", el["near"], el["near_cut"]) == PURCHASE_LABEL + assert safety.click_risk(el) == Risk.send + field = {"name": "Email", "form_submit_text": name, "near": el["near"], "near_cut": el["near_cut"]} + assert safety.type_risk(field, submit=True) == Risk.send + + +@pytest.mark.parametrize("near", [ + "Free updates. Subscription 499 monthly. Subscribe", + "Free weekly digest, billed annually at 4,999. Subscribe", + "Free updates. 12 quarterly issues. Subscribe", +]) +def test_an_amount_next_to_a_billing_word_is_a_price(near): + """CodeRabbit #382: "499 monthly" is a price even with a free word nearby.""" + assert safety.risk_label_for("Subscribe", "click", near=near) == PURCHASE_LABEL + + +@pytest.mark.parametrize("near", [ + "Our monthly newsletter. Free, no spam. Subscribe", + "The weekly newsletter. Free, no spam. Subscribe", + "Free annual garden report updates. Subscribe", +]) +def test_a_billing_word_alone_is_not_a_price(near): + assert safety.risk_label_for("Subscribe", "click", near=near) == "Signs up" + + +def test_free_sign_ups_keep_their_risk(): + assert safety.click_risk({"role": "button", "name": "Follow"}) == Risk.write + assert safety.click_risk({"role": "button", "name": "Notify me"}) == Risk.write + assert safety.click_risk({"role": "button", "name": "Subscribe", "near": "Free newsletter. Subscribe"}) == Risk.send + + +@pytest.mark.parametrize("name, near", [ + (PAID_BUTTON, ""), + ("Subscribe to newsletter", "Free for 14 days, then 199 INR"), + ("Subscribe for $5", ""), + ("Subscribe", "Pro plan ₹499/month Subscribe"), + ("Subscribe", "Start your 7-day free trial"), + ("Subscribe", "Billed yearly at 4,999 INR"), + ("Follow", "Upgrade to follow more creators"), + ("Notify me", "Price drops to 20 EUR per month"), + ("Sign up", "Premium membership"), + ("Subscribe and pay", ""), + ("Buy now", ""), +]) +def test_paid_subscribe_stays_purchase(name, near): + assert safety.risk_label_for(name, "click", near=near) == PURCHASE_LABEL + + +def test_other_labels_unchanged(): + assert safety.risk_label_for("Place order", "click") == "Purchase" + assert safety.risk_label_for("Delete my account", "click") == "Deletes" + assert safety.risk_label_for("Unsubscribe", "click") == "Clicks" + assert safety.risk_label_for("Next page", "click") == "Clicks" + + +def _service_with(elements: list[dict]): + app = make_app() + app.config.tools.approvals.mode = "ask" + svc = BrowserService(app) + app.browser = svc + svc._snap = {"page": None, "url": "http://shop.test/", "refs": {e["ref"]: e for e in elements}} + return svc, make_ctx(app) + + +async def test_free_subscribe_is_not_a_purchase_but_paid_one_is(): + svc, ctx = _service_with([ + {"ref": "e1", "role": "button", "tag": "button", "name": "Subscribe", + "near": "Get our weekly newsletter. Free, no spam. Subscribe"}, + {"ref": "e2", "role": "button", "tag": "button", "name": PAID_BUTTON, + "near": "Pro plan " + PAID_BUTTON}, + {"ref": "e3", "role": "button", "tag": "button", "name": "Subscribe", "near": "Pro plan ₹499/month Subscribe"}, + ]) + click = bt.browser_click + # all are still outward clicks, so they ask under the normal rules + assert svc.risk_for("click", {"ref": "e1"}) == Risk.send + assert svc.risk_for("click", {"ref": "e2"}) == Risk.send + assert svc.describe_for("click", {"ref": "e1"}) == {"risk_label": "Signs up", "target": "Subscribe"} + assert svc.describe_for("click", {"ref": "e2"})["risk_label"] == PURCHASE_LABEL + assert svc.describe_for("click", {"ref": "e3"})["risk_label"] == PURCHASE_LABEL + assert not await is_purchase(click, {"ref": "e1"}, ctx, Risk.send) + assert await is_purchase(click, {"ref": "e2"}, ctx, Risk.send) + assert await is_purchase(click, {"ref": "e3"}, ctx, Risk.send) + + +async def test_live_snapshot_sees_the_price_beside_a_button(browser, site): + """Identical "Subscribe" buttons: those in priced cards are purchases, the free one is a sign-up.""" + ctx = make_ctx(browser.app) + await bt.browser_open.call(ctx, {"url": f"{site}/signup.html"}) + refs = [r for r, e in browser._snap["refs"].items() if e.get("name") == "Subscribe"] + assert len(refs) == 4 + labels = [browser.describe_for("click", {"ref": r})["risk_label"] for r in refs] + # the third card's price sits two levels up; the fourth card has more text than one small card holds + assert labels == ["Signs up", PURCHASE_LABEL, PURCHASE_LABEL, PURCHASE_LABEL] + assert "₹499/month" in browser._snap["refs"][refs[1]]["near"] + assert "₹499" not in browser._snap["refs"][refs[0]]["near"] + + +async def test_live_priced_card_whose_context_stops_early_is_a_purchase(browser, site): + """The context stops at the "Choose" button before the price. With no free word, or with a free word but no whole + card read, it must stay a purchase so an allow rule can never let it through.""" + ctx = make_ctx(browser.app) + await bt.browser_open.call(ctx, {"url": f"{site}/signup-choose.html"}) + refs = browser._snap["refs"] + for name in ("Subscribe", "Subscribe to the newsletter"): + ref = next(r for r, e in refs.items() if e.get("name") == name) + assert refs[ref]["near_cut"] is True + assert "2,999" not in refs[ref]["near"] and "1,499" not in refs[ref]["near"] # the price really is out of reach + assert browser.describe_for("click", {"ref": ref})["risk_label"] == PURCHASE_LABEL, name diff --git a/tests/test_approval_rules.py b/tests/test_approval_rules.py index cc5187d3b..1a486adae 100644 --- a/tests/test_approval_rules.py +++ b/tests/test_approval_rules.py @@ -352,3 +352,42 @@ def test_tools_catalog_keeps_never_tools_for_settings(client): client.patch("/api/config", json={"tools": {"approvals": {"rules": {"current_datetime": "never"}}}}) names = {t["name"] for p in client.get("/api/tools").json() for t in p["tools"]} assert "current_datetime" in names + + +async def test_allow_rule_lets_a_free_signup_through_but_a_paid_subscribe_still_asks(config, isolated_home): + """#378: "Signs up" follows the normal rules; a subscribe that shows a price stays a purchase and always asks.""" + from sentient.browser import safety + from tests.browser.test_signup_label import PAID_BUTTON + + log: list[str] = [] + + def describe(arguments, ctx): + label = str(arguments.get("label", "")) + return {"risk_label": safety.risk_label_for(label, "click"), "target": label} + + @tool("signup_click", risk=Risk.send, describe_fn=describe) + async def signup_click(ctx: ToolContext, label: str) -> dict: + """Click a button on the newsletter page.""" + log.append(label) + return {"clicked": label} + + class Signup(ToolPlugin): + id = "signup" + display_name = "Signup" + tools = [signup_click] + + config.tools.approvals.mode = "ask" + config.tools.approvals.rules = {"signup": "allow"} + llm = FakeProvider(replies=[ + [tool_call("signup_click", label="Subscribe to newsletter")], "ok1", + [tool_call("signup_click", label=PAID_BUTTON)], "ok2", + ]) + s = await _start(config, isolated_home, llm, "signup", Signup()) + try: + sid = await s.store.create_session(channel="cli") + assert (await _turn(s, sid, "subscribe me"))[0] == [] + asked, _ = await _turn(s, sid, "subscribe to pro", decision="deny") + assert [a.risk_label for a in asked] == ["Purchase"] and asked[0].choices == ["allow", "deny"] + assert log == ["Subscribe to newsletter"] + finally: + await s.stop()