feat: record risk policy decisions
This commit is contained in:
138
src/engine.ts
138
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<TopologyGraph>
|
||||
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<TopologyGraph>
|
||||
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<TopologyGraph>
|
||||
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<TopologyGraph>
|
||||
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<T extends Record<K, string>, 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];
|
||||
|
||||
106
src/risk.ts
106
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()));
|
||||
}
|
||||
|
||||
32
src/types.ts
32
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;
|
||||
|
||||
Reference in New Issue
Block a user