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

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