From 347d68626ebfc98cf567affe8d0d5845ed90f84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B9=89=E4=BB=91?= Date: Tue, 16 Jun 2026 20:23:08 +0800 Subject: [PATCH] feat: record risk policy decisions --- src/engine.ts | 138 +++++++++++++++++++++++++++++-- src/risk.ts | 106 ++++++++++++++++++++---- src/types.ts | 32 ++++++- tests/fixtures/context-risk.html | 1 + tests/integration.test.ts | 59 +++++++++++++ tests/risk.test.ts | 28 ++++++- 6 files changed, 338 insertions(+), 26 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 11c956c..25409b5 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -5,9 +5,9 @@ import { chromium, type Browser, type BrowserContext, type Page, type Response } import { shortHash } from "./hash.js"; import { resolveLocator } from "./locator.js"; import { buildEmptyGraph, defaultViewport } from "./metadata.js"; -import { isLowRiskClickable, classifyClickRisk } from "./risk.js"; +import { defaultExploreDecision } from "./risk.js"; import { snapshotPage } from "./snapshot.js"; -import type { ActionEdge, EffectRecord, TopologyGraph } from "./types.js"; +import type { ActionableElement, ActionEdge, EffectRecord, SnapshotResult, TopologyGraph } from "./types.js"; export interface EngineOptions { url: string; @@ -60,7 +60,21 @@ export async function exploreUrl(options: EngineOptions): Promise graph.states.push(entrySnapshot.state); graph.elements.push(...entrySnapshot.elements); - const candidates = entrySnapshot.elements.filter(isLowRiskClickable).slice(0, options.maxActions ?? 10); + recordSkippedActionsForState(graph, entrySnapshot); + + const lowRiskCandidates: ActionableElement[] = []; + for (const element of entrySnapshot.elements) { + if (!isDefaultClickCandidate(element)) continue; + if (!isActionableForClick(element)) { + recordActionabilityFailure(graph, entrySnapshot, element); + continue; + } + const decision = defaultExploreDecision(element, "click"); + if (!decision.allowed) continue; + lowRiskCandidates.push(element); + } + lowRiskCandidates.sort(compareExploreCandidatePriority); + const candidates = lowRiskCandidates.slice(0, options.maxActions ?? 10); await page.close(); for (const element of candidates) { @@ -76,6 +90,7 @@ export async function exploreUrl(options: EngineOptions): Promise const beforeUrl = actionPage.url(); const beforeSnapshot = await snapshotPage(actionPage); const locator = element.locators.find((candidate) => candidate.verified) ?? element.locators[0]; + const decision = defaultExploreDecision(element, "click"); let toStateId: string | null = null; const effects: EffectRecord[] = []; @@ -86,13 +101,35 @@ export async function exploreUrl(options: EngineOptions): Promise const afterSnapshot = await snapshotPage(actionPage); graph.states = upsertById(graph.states, afterSnapshot.state, "stateId"); graph.elements.push(...afterSnapshot.elements); + recordSkippedActionsForState(graph, afterSnapshot); toStateId = afterSnapshot.state.stateId; effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText)); } catch (error) { + const message = error instanceof Error ? error.message : String(error); effects.push({ type: "dom", - description: `Action failed: ${error instanceof Error ? error.message : String(error)}`, + description: `Action failed: ${message}`, }); + graph.failedObservations = upsertById( + graph.failedObservations, + { + failedObservationId: `fail_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click", "unexpected_state", message])}`, + stateId: beforeSnapshot.state.stateId, + elementId: element.elementId, + actionType: "click" as const, + reason: "unexpected_state", + message: `Action failed: ${message}`, + evidence: [ + { + level: "observed", + source: "engine.action", + description: "Click action raised before effects could be fully observed", + }, + ], + createdAt: new Date().toISOString(), + }, + "failedObservationId", + ); } } @@ -106,7 +143,8 @@ export async function exploreUrl(options: EngineOptions): Promise preconditions: [], effects: [...effects, ...networkEvents], parameterSpecs: [], - riskLevel: classifyClickRisk(element), + riskLevel: decision.risk.level, + riskCategory: decision.risk.category, confidence: locator?.verified ? 0.82 : 0.55, createdAt: new Date().toISOString(), }; @@ -173,6 +211,96 @@ function responseToEffect(response: Response): EffectRecord | null { }; } +function recordSkippedActionsForState(graph: TopologyGraph, snapshot: SnapshotResult): void { + for (const element of snapshot.elements) { + if (!isDefaultClickCandidate(element)) continue; + if (!isActionableForClick(element)) { + recordActionabilityFailure(graph, snapshot, element); + continue; + } + const decision = defaultExploreDecision(element, "click"); + if (decision.allowed) continue; + graph.skippedActions = upsertById( + graph.skippedActions, + { + skippedActionId: `skip_${shortHash([snapshot.state.stateId, element.elementId, "click", decision.risk.category])}`, + stateId: snapshot.state.stateId, + elementId: element.elementId, + actionType: "click" as const, + riskLevel: decision.risk.level, + riskCategory: decision.risk.category, + reason: decision.reason, + evidence: decision.risk.evidence, + createdAt: new Date().toISOString(), + }, + "skippedActionId", + ); + } +} + +function recordActionabilityFailure(graph: TopologyGraph, snapshot: SnapshotResult, element: ActionableElement): void { + const reason = actionabilityFailureReason(element); + if (!reason) return; + const messages = { + element_hidden: "Element is hidden and cannot be clicked", + element_disabled: "Element is disabled and cannot be clicked", + element_obscured: "Element does not receive pointer events at its center point", + }; + graph.failedObservations = upsertById( + graph.failedObservations, + { + failedObservationId: `fail_${shortHash([snapshot.state.stateId, element.elementId, "click", reason])}`, + stateId: snapshot.state.stateId, + elementId: element.elementId, + actionType: "click" as const, + reason, + message: messages[reason], + evidence: [ + { + level: "observed", + source: "engine.actionability", + description: `visible=${element.interactability.visible}; enabled=${element.interactability.enabled}; receivesEvents=${element.interactability.receivesEvents}`, + }, + ], + createdAt: new Date().toISOString(), + }, + "failedObservationId", + ); +} + +function isDefaultClickCandidate(element: ActionableElement): boolean { + const role = element.role; + return ( + element.tag === "button" || + element.tag === "a" || + role === "button" || + role === "link" || + role === "tab" || + role === "menuitem" + ); +} + +function isActionableForClick(element: ActionableElement): boolean { + if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false; + return isDefaultClickCandidate(element); +} + +function compareExploreCandidatePriority(left: ActionableElement, right: ActionableElement): number { + return candidatePriority(left) - candidatePriority(right); +} + +function candidatePriority(element: ActionableElement): number { + // Keep initially visible dialog controls from starving non-dialog entry controls under maxActions. + return element.context.dialog ? 1 : 0; +} + +function actionabilityFailureReason(element: ActionableElement): "element_hidden" | "element_disabled" | "element_obscured" | null { + if (!element.interactability.visible) return "element_hidden"; + if (!element.interactability.enabled) return "element_disabled"; + if (!element.interactability.receivesEvents) return "element_obscured"; + return null; +} + function upsertById, K extends keyof T>(items: T[], item: T, key: K): T[] { const index = items.findIndex((existing) => existing[key] === item[key]); if (index === -1) return [...items, item]; diff --git a/src/risk.ts b/src/risk.ts index d411c34..1d45476 100644 --- a/src/risk.ts +++ b/src/risk.ts @@ -1,4 +1,4 @@ -import type { ActionableElement } from "./types.js"; +import type { ActionableElement, ActionType, RiskClassification, RiskPolicyDecision } from "./types.js"; const HIGH_RISK_TERMS = [ "delete", @@ -6,10 +6,15 @@ const HIGH_RISK_TERMS = [ "destroy", "drop", "logout", + "log out", "sign out", + "signin", + "sign in", "pay", + "payment", "purchase", "checkout", + "check out", "submit", "confirm", "authorize", @@ -24,28 +29,93 @@ const HIGH_RISK_TERMS = [ "授权", ]; -export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" { - const joined = [ - element.accessibleName, - element.text, - element.label, - element.placeholder, - element.attributes.href, - element.attributes.type, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); +const DESTRUCTIVE_TERMS = ["delete", "remove", "destroy", "drop", "删除", "移除"]; +const PAYMENT_TERMS = ["pay", "payment", "purchase", "checkout", "check out", "支付", "购买"]; +const AUTH_TERMS = ["authorize", "login", "log in", "logout", "log out", "signin", "sign in", "sign out", "授权", "注销", "退出"]; +const SUBMIT_TERMS = ["submit", "confirm", "提交", "确认"]; - if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) return "high"; - if (element.tag === "input" || element.tag === "textarea" || element.tag === "select") return "medium"; - return "low"; +export function classifyActionRisk(element: ActionableElement, actionType: ActionType): RiskClassification { + const joined = riskText(element); + const reasons: string[] = []; + if (containsAny(joined, DESTRUCTIVE_TERMS)) { + reasons.push("matched destructive action term"); + return risk("high", "destructive", reasons); + } + if (containsAny(joined, PAYMENT_TERMS)) { + reasons.push("matched payment-sensitive action term"); + return risk("high", "payment_sensitive", reasons); + } + if (containsAny(joined, AUTH_TERMS)) { + reasons.push("matched auth-sensitive action term"); + return risk("high", "auth_sensitive", reasons); + } + if (containsAny(joined, SUBMIT_TERMS) || element.attributes.type === "submit") { + reasons.push("matched form submission action term or submit type"); + return risk("high", "form_submit", reasons); + } + if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) { + reasons.push("matched high-risk fallback term"); + return risk("high", "unknown", reasons); + } + if (actionType === "fill" || element.tag === "input" || element.tag === "textarea" || element.tag === "select") { + reasons.push("editable control captured but not default-explored"); + return risk("medium", "input_edit", reasons); + } + if (element.tag === "a" || element.role === "link" || element.attributes.href) { + reasons.push("link or href navigation"); + return risk("low", "navigation", reasons); + } + if ( + element.role === "button" || + element.role === "tab" || + element.role === "menuitem" || + element.attributes["aria-haspopup"] || + element.attributes["aria-expanded"] + ) { + reasons.push("button-like UI reveal candidate"); + return risk("low", "ui_reveal", reasons); + } + reasons.push("no low-risk semantic evidence"); + return risk("high", "unknown", reasons); +} + +export function defaultExploreDecision(element: ActionableElement, actionType: ActionType): RiskPolicyDecision { + const risk = classifyActionRisk(element, actionType); + const allowed = risk.level === "low" && ["navigation", "ui_reveal", "read_only"].includes(risk.category); + return { + allowed, + risk, + reason: allowed ? `allowed:${risk.category}` : `blocked:${risk.category}`, + }; +} + +export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" { + return classifyActionRisk(element, "click").level; } export function isLowRiskClickable(element: ActionableElement): boolean { if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false; - if (classifyClickRisk(element) !== "low") return false; const role = element.role; - return element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem"; + const clickable = element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem"; + return clickable && defaultExploreDecision(element, "click").allowed; } +function risk(level: RiskClassification["level"], category: RiskClassification["category"], reasons: string[]): RiskClassification { + return { + level, + category, + reasons, + evidence: [{ level: "heuristic", source: "risk.policy", description: reasons.join("; ") }], + }; +} + +function riskText(element: ActionableElement): string { + return [element.accessibleName, element.text, element.label, element.placeholder, element.attributes.href, element.attributes.type] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +function containsAny(value: string, terms: string[]): boolean { + return terms.some((term) => value.includes(term.toLowerCase())); +} diff --git a/src/types.ts b/src/types.ts index 0c287e4..88e025f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,32 @@ export interface EvidenceRecord { description: string; } +export type RiskLevel = "low" | "medium" | "high"; +export type RiskCategory = + | "read_only" + | "navigation" + | "ui_reveal" + | "input_edit" + | "form_submit" + | "data_mutation" + | "destructive" + | "auth_sensitive" + | "payment_sensitive" + | "unknown"; + +export interface RiskClassification { + level: RiskLevel; + category: RiskCategory; + reasons: string[]; + evidence: EvidenceRecord[]; +} + +export interface RiskPolicyDecision { + allowed: boolean; + risk: RiskClassification; + reason: string; +} + export interface ContextAnchor { kind: "dialog" | "form" | "section" | "row" | "landmark" | "listitem"; name: string | null; @@ -174,7 +200,8 @@ export interface ActionEdge { preconditions: string[]; effects: EffectRecord[]; parameterSpecs: ParameterSpec[]; - riskLevel: "low" | "medium" | "high"; + riskLevel: RiskLevel; + riskCategory: RiskCategory; confidence: number; createdAt: string; } @@ -212,7 +239,8 @@ export interface SkippedAction { stateId: string; elementId: string; actionType: ActionType; - riskLevel: "low" | "medium" | "high"; + riskLevel: RiskLevel; + riskCategory: RiskCategory; reason: string; evidence: EvidenceRecord[]; createdAt: string; diff --git a/tests/fixtures/context-risk.html b/tests/fixtures/context-risk.html index c3b9055..ef01f93 100644 --- a/tests/fixtures/context-risk.html +++ b/tests/fixtures/context-risk.html @@ -95,6 +95,7 @@ +