feat: record risk policy decisions

This commit is contained in:
赵义仑
2026-06-16 20:23:08 +08:00
parent c41f260e51
commit 347d68626e
6 changed files with 338 additions and 26 deletions

View File

@@ -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];

View File

@@ -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()));
}

View File

@@ -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;

View File

@@ -95,6 +95,7 @@
</section>
<button disabled data-testid="disabled-open">Open disabled panel</button>
<button disabled data-testid="disabled-delete">Delete disabled account</button>
<button data-testid="open-settings" onclick="document.getElementById('settings-dialog').classList.add('open')">Open settings</button>
<section id="settings-dialog" role="dialog" aria-modal="true" aria-label="Settings dialog">
<h2>Settings</h2>

View File

@@ -9,6 +9,7 @@ import { shortHash } from "../src/hash.js";
const fixturePath = path.resolve("tests/fixtures/context-risk.html");
const fixtureFileUrl = pathToFileURL(fixturePath).href;
const examplePath = path.resolve("examples/simple.html");
async function withTempOutput<T>(name: string, callback: (out: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(tmpdir(), "action-topology-engine-test-"));
@@ -196,3 +197,61 @@ test("explore records low-risk edges and keeps high-risk submit actions out of d
assert.equal(hasEdgeForAccessibleName(graph, "Submit search"), false);
assert.equal(graph.states.length >= 2, true);
});
test("explore records state-local skipped high-risk actions with evidence", async () => {
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account");
assert.ok(deleteElement);
const deleteSkip = graph.skippedActions.find((skip) => skip.elementId === deleteElement.elementId);
assert.ok(deleteSkip);
assert.equal(deleteSkip.stateId, deleteElement.stateId);
assert.equal(deleteSkip.riskCategory, "destructive");
assert.equal(deleteSkip.riskLevel, "high");
assert.equal(graph.skippedActions.some((skip) => skip.reason === "allowed:ui_reveal"), false);
assert.equal(graph.failedObservations.every((failure) => failure.message.length > 0), true);
});
test("disabled low-risk controls are failed observations, not risk skips", async () => {
const graph = await withTempOutput("explore-disabled", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
const disabled = graph.elements.find((element) => element.accessibleName === "Open disabled panel");
assert.ok(disabled);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === disabled.elementId), false);
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === disabled.elementId && failure.reason === "element_disabled",
),
true,
);
});
test("explore does not record non-click elements as skipped click actions", async () => {
const graph = await withTempOutput("explore-example-skips", (out) => exploreUrl({ url: examplePath, maxActions: 8, out }));
const searchInput = graph.elements.find((element) => element.accessibleName === "Search");
assert.ok(searchInput);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === searchInput.elementId), false);
const settingsDialog = graph.elements.find((element) => element.accessibleName === "Settings dialog");
assert.ok(settingsDialog);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === settingsDialog.elementId), false);
const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account");
assert.ok(deleteElement);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === deleteElement.elementId && skip.riskCategory === "destructive"), true);
});
test("disabled high-risk click candidates are failed observations, not risk skips", async () => {
const graph = await withTempOutput("explore-disabled-high-risk", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
const disabled = graph.elements.find((element) => element.accessibleName === "Delete disabled account");
assert.ok(disabled);
assert.equal(graph.skippedActions.some((skip) => skip.elementId === disabled.elementId), false);
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === disabled.elementId && failure.reason === "element_disabled",
),
true,
);
});

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { classifyClickRisk, isLowRiskClickable } from "../src/risk.js";
import { classifyActionRisk, classifyClickRisk, defaultExploreDecision, isLowRiskClickable } from "../src/risk.js";
import type { ActionableElement } from "../src/types.js";
function element(overrides: Partial<ActionableElement>): ActionableElement {
@@ -49,3 +49,29 @@ test("default explorer only clicks low-risk visible clickable elements", () => {
assert.equal(isLowRiskClickable(element({ accessibleName: "Delete account" })), false);
assert.equal(isLowRiskClickable(element({ interactability: { visible: true, enabled: false, editable: false, receivesEvents: true } })), false);
});
test("risk policy exposes category, level, and reasons", () => {
const decision = defaultExploreDecision(element({ accessibleName: "Authorize payment" }), "click");
assert.equal(decision.allowed, false);
assert.equal(decision.risk.category, "payment_sensitive");
assert.equal(decision.risk.level, "high");
assert.equal(decision.reason.includes("payment_sensitive"), true);
});
test("risk policy treats UI reveal as default explorable", () => {
const risk = classifyActionRisk(element({ accessibleName: "Open settings" }), "click");
assert.equal(risk.category, "ui_reveal");
assert.equal(defaultExploreDecision(element({ accessibleName: "Open settings" }), "click").allowed, true);
});
test("risk policy blocks spaced auth and payment phrases", () => {
const logOut = defaultExploreDecision(element({ accessibleName: "Log out" }), "click");
assert.equal(logOut.allowed, false);
assert.equal(logOut.risk.category, "auth_sensitive");
assert.equal(logOut.risk.level, "high");
const checkOut = defaultExploreDecision(element({ accessibleName: "Check out" }), "click");
assert.equal(checkOut.allowed, false);
assert.equal(checkOut.risk.category, "payment_sensitive");
assert.equal(checkOut.risk.level, "high");
});