From 91e466f339250ed5b94aba2f73044a299246fbbe Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 19:44:38 +0530 Subject: [PATCH 1/6] fix(browser): label a free subscribe button "Signs up", not Purchase A subscribe, follow, sign up or "notify me" with no price, currency, billing period, plan, trial or upgrade on it or in its card now reads "Signs up" and follows the normal approval rules. Anything that may cost money stays "Purchase" and always asks. The page snapshot records the text around sign-up looking elements (stopping before the next offer's card) so a price beside the button is seen. The approval card names "Signs up" with its own wording and icon. Closes #378 --- CHANGELOG.md | 4 + desktop/src/features/chat/ApprovalCard.tsx | 4 +- desktop/src/features/chat/approvalRisk.ts | 6 ++ desktop/tests/approvalRisk.test.ts | 23 ++++++ docs/API.md | 6 +- sentient/browser/safety.py | 41 +++++++++-- sentient/browser/service.py | 3 +- sentient/browser/snapshot.py | 34 +++++++++ sentient/tools/rules.py | 2 +- tests/browser/conftest.py | 5 ++ tests/browser/test_signup_label.py | 86 ++++++++++++++++++++++ tests/test_approval_rules.py | 39 ++++++++++ 12 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 tests/browser/test_signup_label.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4dbdc37..27a12b862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -403,6 +403,10 @@ 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 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 0063c5619..3d0f15a9e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1830,7 +1830,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. @@ -2194,6 +2194,10 @@ 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) unless the element or the text around it (its card or form) + shows a price, currency, billing period ("/month", "per seat"), plan, trial or upgrade; then it stays "Purchase", + which always asks. 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..bc9fef27c 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`` is the text around the +element (its card or form). 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,24 @@ (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: a sign-up, which costs money only when the button or its surroundings say so (#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" + r"|\b(?:plans?|trial|upgrade|premium|pricing|prices?|billed|billing|" + r"paid|pay|payment|buy|purchase|checkout|membership|pro)\b", + re.IGNORECASE, +) def approval_target(el: dict | None) -> str: @@ -54,10 +73,20 @@ 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 risk_label_for(text: str, kind: str, near: str = "") -> 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 neither the element nor ``near`` shows a price, currency, billing period, plan, trial or + upgrade. Otherwise it may cost money, so it stays 'Purchase', which always asks.""" + text = text or "" + purchase_rule, *other_rules = _LABEL_RULES + if purchase_rule[0].search(text): + return purchase_rule[1] + if _SIGNUP_RE.search(text): + return purchase_rule[1] if _PAID_RE.search(f"{text} {near or ''}") else SIGNUP_LABEL + for rx, label in other_rules: + if rx.search(text): return label return _DEFAULT_LABELS.get(kind, "Changes something") diff --git a/sentient/browser/service.py b/sentient/browser/service.py index 79a157fc7..8740ff143 100644 --- a/sentient/browser/service.py +++ b/sentient/browser/service.py @@ -1418,7 +1418,8 @@ 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 "") + return {"risk_label": safety.risk_label_for(context, kind, near), "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..3d022cd03 100644 --- a/sentient/browser/snapshot.py +++ b/sentient/browser/snapshot.py @@ -133,6 +133,39 @@ 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) or much more text than one card; when even the first parent is large, it keeps the text just around 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 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 ''; + let near = ''; + let node = el.parentElement; + for (let i = 0; i < 4 && node && node !== document.documentElement; i++) { + const big = node === document.body || (node.textContent || '').length > 4000; + const text = big ? '' : (node.innerText || '').replace(/\s+/g, ' ').trim(); + if (i > 0 && !big && text.length <= 400 && otherOffer(node, el)) break; + if (big || text.length > 400) { + if (i === 0 && (node.textContent || '').length < 20000) { + const all = (node.innerText || '').replace(/\s+/g, ' '); + const mine = (el.innerText || el.value || '').replace(/\s+/g, ' ').trim(); + const at = mine ? all.indexOf(mine) : -1; + if (at >= 0) near = all.slice(Math.max(0, at - 150), at + mine.length + 150).trim(); + } + break; + } + near = text; + node = node.parentElement; + } + return clip(near, 400); +} const els = []; let n = 0; const max = args.maxElements || 300; @@ -168,6 +201,7 @@ d.value = sel ? clip(sel.label || sel.text, 60) : ''; } Object.assign(d, formInfo(el)); + d.near = nearText(el, d); d.focused = document.activeElement === el; els.push(d); } diff --git a/sentient/tools/rules.py b/sentient/tools/rules.py index 2e81bbe8d..138e86911 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..b20dce010 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -49,6 +49,11 @@ "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
+
""", } diff --git a/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py new file mode 100644 index 000000000..c94b9a57c --- /dev/null +++ b/tests/browser/test_signup_label.py @@ -0,0 +1,86 @@ +"""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", "Subscribe to newsletter", "Follow", "Notify me", "Sign up", "Get updates", "Join the waitlist", +]) +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" + + +@pytest.mark.parametrize("name, near", [ + (PAID_BUTTON, ""), + ("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) == 3 + labels = [browser.describe_for("click", {"ref": r})["risk_label"] for r in refs] + assert labels == ["Signs up", PURCHASE_LABEL, PURCHASE_LABEL] # the third card's price sits two levels up + assert "₹499/month" in browser._snap["refs"][refs[1]]["near"] + assert "₹499" not in browser._snap["refs"][refs[0]]["near"] 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() From c0903f6e3bc88360d78bcfb5c800e8d33cd46b18 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 20:31:59 +0530 Subject: [PATCH 2/6] fix(browser): keep the price from a larger card above a small button wrapper When a Subscribe button sits in a small wrapper inside a card with more text, the snapshot now keeps the text just before and after the button inside that card, so a price above the wrapper still makes it a Purchase. --- sentient/browser/snapshot.py | 25 +++++++++++++------------ tests/browser/conftest.py | 2 ++ tests/browser/test_signup_label.py | 5 +++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/sentient/browser/snapshot.py b/sentient/browser/snapshot.py index 3d022cd03..7b77ec847 100644 --- a/sentient/browser/snapshot.py +++ b/sentient/browser/snapshot.py @@ -135,7 +135,7 @@ } // 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) or much more text than one card; when even the first parent is large, it keeps the text just around the element. +// 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]'; @@ -143,28 +143,29 @@ 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 ''; let near = ''; let node = el.parentElement; for (let i = 0; i < 4 && node && node !== document.documentElement; i++) { - const big = node === document.body || (node.textContent || '').length > 4000; - const text = big ? '' : (node.innerText || '').replace(/\s+/g, ' ').trim(); - if (i > 0 && !big && text.length <= 400 && otherOffer(node, el)) break; - if (big || text.length > 400) { - if (i === 0 && (node.textContent || '').length < 20000) { - const all = (node.innerText || '').replace(/\s+/g, ' '); - const mine = (el.innerText || el.value || '').replace(/\s+/g, ' ').trim(); - const at = mine ? all.indexOf(mine) : -1; - if (at >= 0) near = all.slice(Math.max(0, at - 150), at + mine.length + 150).trim(); - } + const huge = node === document.body || (node.textContent || '').length > 20000; + if (i > 0 && !huge && otherOffer(node, el)) 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) near = [windowText(node, el, true).slice(-400), near, windowText(node, el, false).slice(0, 200)].join(' '); break; } near = text; node = node.parentElement; } - return clip(near, 400); + return clip(near, 700); } const els = []; let n = 0; diff --git a/tests/browser/conftest.py b/tests/browser/conftest.py index b20dce010..93c4ba502 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -53,6 +53,8 @@

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
""", } diff --git a/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py index c94b9a57c..aa135d317 100644 --- a/tests/browser/test_signup_label.py +++ b/tests/browser/test_signup_label.py @@ -79,8 +79,9 @@ async def test_live_snapshot_sees_the_price_beside_a_button(browser, site): 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) == 3 + assert len(refs) == 4 labels = [browser.describe_for("click", {"ref": r})["risk_label"] for r in refs] - assert labels == ["Signs up", PURCHASE_LABEL, PURCHASE_LABEL] # the third card's price sits two levels up + # 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"] From 8e1ac6d95175e9b96a471871672cb35436abc10b Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 21:00:27 +0530 Subject: [PATCH 3/6] fix(browser): "Signs up" needs a free signal, a bare Subscribe stays Purchase A subscribe, follow or sign up now reads "Signs up" only when the button or its card says what free thing it is (newsletter, updates, waitlist, alerts, "free" ...) and shows no money signal. A priced card whose context stops early at another offer button, or any bare "Subscribe", stays a purchase and always asks, so an allow rule can never let a paid subscribe through. --- CHANGELOG.md | 3 ++- docs/API.md | 7 ++++--- sentient/browser/safety.py | 17 +++++++++++++---- tests/browser/conftest.py | 5 +++++ tests/browser/test_signup_label.py | 19 ++++++++++++++++++- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a12b862..410d0183f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -406,7 +406,8 @@ and versions follow [Semantic Versioning](https://semver.org/). - **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 still reads as a purchase and always asks. + 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/docs/API.md b/docs/API.md index 3d0f15a9e..0edeb656f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2195,9 +2195,10 @@ Every new tool declares a `Risk`; approvals behave as in section 1. 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) unless the element or the text around it (its card or form) - shows a price, currency, billing period ("/month", "per seat"), plan, trial or upgrade; then it stays "Purchase", - which always asks. + "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"), plan, trial or upgrade. Anything else, a bare "Subscribe" included, stays + "Purchase", which always asks. 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 bc9fef27c..a86769595 100644 --- a/sentient/browser/safety.py +++ b/sentient/browser/safety.py @@ -44,7 +44,7 @@ _DEFAULT_LABELS = {"click": "Clicks", "type": "Types into the page", "press": "Presses a key"} SIGNUP_LABEL = "Signs up" -# subscribe, follow, notify me: a sign-up, which costs money only when the button or its surroundings say so (#378) +# 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", @@ -60,6 +60,12 @@ 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: @@ -77,14 +83,17 @@ def risk_label_for(text: str, kind: str, near: str = "") -> 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 neither the element nor ``near`` shows a price, currency, billing period, plan, trial or - upgrade. Otherwise it may cost money, so it stays 'Purchase', which always asks.""" + '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 stays 'Purchase', which always asks: a bare "Subscribe" is a purchase.""" text = text or "" purchase_rule, *other_rules = _LABEL_RULES if purchase_rule[0].search(text): return purchase_rule[1] if _SIGNUP_RE.search(text): - return purchase_rule[1] if _PAID_RE.search(f"{text} {near or ''}") else SIGNUP_LABEL + context = f"{text} {near or ''}" + free = _FREE_RE.search(context) and not _PAID_RE.search(context) + return SIGNUP_LABEL if free else purchase_rule[1] for rx, label in other_rules: if rx.search(text): return label diff --git a/tests/browser/conftest.py b/tests/browser/conftest.py index 93c4ba502..7b393f534 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -55,6 +55,11 @@

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.

+
""", } diff --git a/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py index aa135d317..387341d97 100644 --- a/tests/browser/test_signup_label.py +++ b/tests/browser/test_signup_label.py @@ -15,15 +15,23 @@ @pytest.mark.parametrize("name", [ - "Subscribe", "Subscribe to newsletter", "Follow", "Notify me", "Sign up", "Get updates", "Join the waitlist", + "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" +@pytest.mark.parametrize("name", ["Subscribe", "Sign up"]) +def test_a_bare_subscribe_needs_a_free_signal_nearby(name): + assert safety.risk_label_for(name, "click") == PURCHASE_LABEL + assert safety.risk_label_for(name, "click", near="Team plan " + name) == PURCHASE_LABEL + assert safety.risk_label_for(name, "click", near="The weekly newsletter. Free, no spam. " + name) == "Signs up" + + @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"), @@ -85,3 +93,12 @@ async def test_live_snapshot_sees_the_price_beside_a_button(browser, site): 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 it must stay a purchase.""" + ctx = make_ctx(browser.app) + await bt.browser_open.call(ctx, {"url": f"{site}/signup-choose.html"}) + ref = next(r for r, e in browser._snap["refs"].items() if e.get("name") == "Subscribe") + assert "2,999" not in browser._snap["refs"][ref]["near"] # the price really is out of reach + assert browser.describe_for("click", {"ref": ref})["risk_label"] == PURCHASE_LABEL From 189903b640d227d8dc1a2619f00f996e9464a520 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 22:02:06 +0530 Subject: [PATCH 4/6] fix(browser): a free word in a cut-short context is not enough for "Signs up" When the snapshot's context stops early at another offer's button and holds little besides the button, a price may sit just out of reach. Such a button (for example "Subscribe to the newsletter" in a priced card next to "Choose") now stays a purchase, so an allow rule can't approve it. --- docs/API.md | 5 +++-- sentient/browser/safety.py | 21 ++++++++++++++++----- sentient/browser/service.py | 3 ++- sentient/browser/snapshot.py | 16 ++++++++++------ tests/browser/conftest.py | 2 ++ tests/browser/test_signup_label.py | 20 ++++++++++++++++---- 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0edeb656f..0b57306cf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2197,8 +2197,9 @@ Every new tool declares a `Risk`; approvals behave as in section 1. 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"), plan, trial or upgrade. Anything else, a bare "Subscribe" included, stays - "Purchase", which always asks. + billing period ("/month", "per seat"), 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. 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 a86769595..7f2d3fc6e 100644 --- a/sentient/browser/safety.py +++ b/sentient/browser/safety.py @@ -9,8 +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, near}``. ``near`` is the text around the -element (its card or form). 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 @@ -79,20 +79,31 @@ 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, near: str = "") -> str: +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 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 stays 'Purchase', which always asks: a bare "Subscribe" is a purchase.""" + may cost money, so it stays 'Purchase', which always asks: a bare "Subscribe" is a purchase. ``near_cut`` says + the context stopped early (at another offer's button); then only a whole card of text can show it is free.""" 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 ''}" - free = _FREE_RE.search(context) and not _PAID_RE.search(context) + free = (_FREE_RE.search(context) and not _PAID_RE.search(context) + and not _partial_offer(text, near, near_cut)) return SIGNUP_LABEL if free else purchase_rule[1] for rx, label in other_rules: if rx.search(text): diff --git a/sentient/browser/service.py b/sentient/browser/service.py index 8740ff143..c658cf2bc 100644 --- a/sentient/browser/service.py +++ b/sentient/browser/service.py @@ -1419,7 +1419,8 @@ def describe_for(self, kind: str, arguments: dict | None) -> dict: target = safety.approval_target(el) context = " ".join([target, str((el or {}).get("form_submit_text") or "")]) if kind == "type" else target near = str((el or {}).get("near") or "") - return {"risk_label": safety.risk_label_for(context, kind, near), "target": target} + 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 7b77ec847..b7273de87 100644 --- a/sentient/browser/snapshot.py +++ b/sentient/browser/snapshot.py @@ -150,22 +150,24 @@ } 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 ''; - let near = ''; + 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)) break; + 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) near = [windowText(node, el, true).slice(-400), near, windowText(node, el, false).slice(0, 200)].join(' '); + 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 clip(near, 700); + return { near: clip(near, 700), cut }; } const els = []; let n = 0; @@ -202,7 +204,9 @@ d.value = sel ? clip(sel.label || sel.text, 60) : ''; } Object.assign(d, formInfo(el)); - d.near = nearText(el, d); + 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/tests/browser/conftest.py b/tests/browser/conftest.py index 7b393f534..3fec6862f 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -60,6 +60,8 @@ "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 index 387341d97..55f8e2aac 100644 --- a/tests/browser/test_signup_label.py +++ b/tests/browser/test_signup_label.py @@ -22,6 +22,14 @@ def test_free_signup_reads_signs_up(name): 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" + + @pytest.mark.parametrize("name", ["Subscribe", "Sign up"]) def test_a_bare_subscribe_needs_a_free_signal_nearby(name): assert safety.risk_label_for(name, "click") == PURCHASE_LABEL @@ -96,9 +104,13 @@ async def test_live_snapshot_sees_the_price_beside_a_button(browser, site): 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 it must stay a purchase.""" + """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"}) - ref = next(r for r, e in browser._snap["refs"].items() if e.get("name") == "Subscribe") - assert "2,999" not in browser._snap["refs"][ref]["near"] # the price really is out of reach - assert browser.describe_for("click", {"ref": ref})["risk_label"] == PURCHASE_LABEL + 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 From a66dfb92d6366273859fc5f51874623c854712fa Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 22:30:35 +0530 Subject: [PATCH 5/6] fix(browser): a sign-up labelled Purchase is always a send A priced or cut-short sign-up that the approval card calls a purchase is now rated send for clicks and Enter-submits, so is_purchase sees it and the purchase hard stop can't be skipped by an allow rule. A bare "Sign up" with no free and no money signal keeps its plain "Clicks" label and write risk, as before. --- docs/API.md | 3 ++- sentient/browser/safety.py | 27 +++++++++++++++++---- tests/browser/test_signup_label.py | 39 ++++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0b57306cf..4b6511572 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2199,7 +2199,8 @@ Every new tool declares a `Risk`; approvals behave as in section 1. form) says what free thing it is (newsletter, updates, waitlist, alerts, "free" ...) and shows no price, currency, billing period ("/month", "per seat"), 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. + 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 7f2d3fc6e..67c4bdfa1 100644 --- a/sentient/browser/safety.py +++ b/sentient/browser/safety.py @@ -88,23 +88,34 @@ def _partial_offer(text: str, near: str, near_cut: bool) -> bool: 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 stays 'Purchase', which always asks: a bare "Subscribe" is a purchase. ``near_cut`` says - the context stopped early (at another offer's button); then only a whole card of text can show it is free.""" + 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 ''}" - free = (_FREE_RE.search(context) and not _PAID_RE.search(context) - and not _partial_offer(text, near, near_cut)) - return SIGNUP_LABEL if free else purchase_rule[1] + 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 @@ -141,6 +152,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 @@ -156,6 +170,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/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py index 55f8e2aac..a74db8d3d 100644 --- a/tests/browser/test_signup_label.py +++ b/tests/browser/test_signup_label.py @@ -30,11 +30,40 @@ def test_a_free_word_in_a_cut_short_context_is_not_enough(): assert safety.risk_label_for("Subscribe", "click", near, True) == "Signs up" -@pytest.mark.parametrize("name", ["Subscribe", "Sign up"]) -def test_a_bare_subscribe_needs_a_free_signal_nearby(name): - assert safety.risk_label_for(name, "click") == PURCHASE_LABEL - assert safety.risk_label_for(name, "click", near="Team plan " + name) == PURCHASE_LABEL - assert safety.risk_label_for(name, "click", near="The weekly newsletter. Free, no spam. " + name) == "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 + + +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", [ From ffd8877cc242b886d77deba2d37b288309e563f7 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Fri, 16 Oct 2026 19:08:28 +0530 Subject: [PATCH 6/6] fix(browser): an amount beside "monthly" or "annually" is a price "Subscription 499 monthly" next to a free word read "Signs up", so an allow rule could let it through without the purchase hard stop. A billing word (monthly, yearly, annually, annual, quarterly, per annum) now counts as a price when an amount sits beside it. "Monthly newsletter" on its own still reads "Signs up". --- docs/API.md | 2 +- sentient/browser/safety.py | 3 +++ tests/browser/test_signup_label.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 4b6511572..94cdd07f8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2197,7 +2197,7 @@ Every new tool declares a `Risk`; approvals behave as in section 1. 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"), plan, trial or upgrade. When that text stops early at another offer's button + 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. diff --git a/sentient/browser/safety.py b/sentient/browser/safety.py index 67c4bdfa1..96e3f38c2 100644 --- a/sentient/browser/safety.py +++ b/sentient/browser/safety.py @@ -56,6 +56,9 @@ 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, diff --git a/tests/browser/test_signup_label.py b/tests/browser/test_signup_label.py index a74db8d3d..0f100811e 100644 --- a/tests/browser/test_signup_label.py +++ b/tests/browser/test_signup_label.py @@ -60,6 +60,25 @@ def test_a_purchase_label_on_a_sign_up_is_always_send(name, near): 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