Merge branch 'codex/n15-report-hardening'
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
# N15 Selector Report Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Harden the N14 offline selector report tests and verifier so ambiguous strict-mode behavior and report-output safety regressions are caught before merge.
|
||||
|
||||
**Architecture:** Keep N15 as a test/verifier-only change. Add an in-memory ambiguous-match test to `internal/uiaselector/report_test.go`, then strengthen `scripts/verify-n14-uia-selector-report.ps1` with stale-output cleanup, file/non-empty checks, and JSON match-field allowlist validation.
|
||||
|
||||
**Tech Stack:** Go 1.23.x tests, existing `internal/uiaselector` package, Windows PowerShell 5.1-compatible verifier script, standard `go test` and repository Git workflow.
|
||||
|
||||
---
|
||||
|
||||
## Scope and boundary
|
||||
|
||||
N15 changes only test and verifier coverage.
|
||||
|
||||
Modify only:
|
||||
|
||||
```text
|
||||
internal/uiaselector/report_test.go
|
||||
scripts/verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
Do not modify:
|
||||
|
||||
```text
|
||||
native/ISphereWinHelper/Program.cs
|
||||
internal/tools/winhelper.go
|
||||
cmd/isphere-mcp/main.go
|
||||
cmd/uia-selector-report/main.go
|
||||
internal/uiaselector/report.go
|
||||
internal/uiaselector/catalog.go
|
||||
internal/uiaselector/matcher.go
|
||||
```
|
||||
|
||||
Do not add selectors, helper ops, MCP tools, CLI arguments, live hwnd/process handling, UI automation calls, clicks, keyboard input, clipboard operations, conversation actions, message sending, file transfer, or visible text export.
|
||||
|
||||
## File structure
|
||||
|
||||
```text
|
||||
internal/uiaselector/report_test.go
|
||||
Add one explicit ambiguous strict-mode regression test using an in-memory UIA tree.
|
||||
|
||||
scripts/verify-n14-uia-selector-report.ps1
|
||||
Clean stale outputs before CLI execution, require fresh non-empty report files, and validate JSON match fields with an allowlist.
|
||||
```
|
||||
|
||||
## Task 1: Cover ambiguous strict-mode classification
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/uiaselector/report_test.go`
|
||||
|
||||
- [ ] **Step 1: Add the ambiguous strict-mode regression test**
|
||||
|
||||
Append this test to `internal/uiaselector/report_test.go`:
|
||||
|
||||
```go
|
||||
func TestStrictModeClassifiesAmbiguousMatches(t *testing.T) {
|
||||
root := &Node{
|
||||
ControlType: "Window",
|
||||
AutomationID: "frmMain",
|
||||
FrameworkID: "WinForm",
|
||||
Children: []Node{
|
||||
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
|
||||
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
|
||||
},
|
||||
}
|
||||
catalog := []Selector{
|
||||
{
|
||||
ID: "duplicate_panel",
|
||||
Description: "duplicate structural selector",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "duplicate", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
StabilityScore: 80,
|
||||
Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/duplicate"},
|
||||
},
|
||||
}
|
||||
|
||||
report := RunCatalogReport("synthetic-ambiguous.json", root, catalog, fixedReportTime())
|
||||
if !report.OK {
|
||||
t.Fatalf("report OK=false: %#v", report.Error)
|
||||
}
|
||||
if report.CatalogSize != 1 {
|
||||
t.Fatalf("catalog_size = %d, want 1", report.CatalogSize)
|
||||
}
|
||||
if report.MatchedCount != 1 {
|
||||
t.Fatalf("matched_count = %d, want 1", report.MatchedCount)
|
||||
}
|
||||
if report.UnmatchedCount != 0 {
|
||||
t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount)
|
||||
}
|
||||
if report.AmbiguousCount != 1 {
|
||||
t.Fatalf("ambiguous_count = %d, want 1", report.AmbiguousCount)
|
||||
}
|
||||
if len(report.Selectors) != 1 {
|
||||
t.Fatalf("len(selectors) = %d, want 1", len(report.Selectors))
|
||||
}
|
||||
if got := report.Selectors[0].MatchCount; got != 2 {
|
||||
t.Fatalf("selector match_count = %d, want 2", got)
|
||||
}
|
||||
if got := StrictExitCode(report); got != StrictExitAmbiguous {
|
||||
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitAmbiguous)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Prove the test catches a broken ambiguous branch**
|
||||
|
||||
Temporarily change `internal/uiaselector/report.go` in `StrictExitCode` from:
|
||||
|
||||
```go
|
||||
if report.AmbiguousCount > 0 {
|
||||
return StrictExitAmbiguous
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
if report.AmbiguousCount > 0 {
|
||||
return StrictExitOK
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
gofmt -w internal\uiaselector\report_test.go
|
||||
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL with:
|
||||
|
||||
```text
|
||||
StrictExitCode = 0, want 3
|
||||
```
|
||||
|
||||
Restore `internal/uiaselector/report.go` immediately after this command so production code again returns `StrictExitAmbiguous` for ambiguous reports. Do not commit the temporary mutation.
|
||||
|
||||
- [ ] **Step 3: Run the ambiguous test with production code restored**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
gofmt -w internal\uiaselector\report_test.go internal\uiaselector\report.go
|
||||
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run package tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit ambiguous strict-mode test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add internal\uiaselector\report_test.go
|
||||
git diff --cached --name-only
|
||||
```
|
||||
|
||||
Expected staged file list:
|
||||
|
||||
```text
|
||||
internal/uiaselector/report_test.go
|
||||
```
|
||||
|
||||
Then commit:
|
||||
|
||||
```powershell
|
||||
git commit -m "test: cover N15 ambiguous strict exit"
|
||||
```
|
||||
|
||||
## Task 2: Harden N14 selector report verifier
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/verify-n14-uia-selector-report.ps1`
|
||||
|
||||
- [ ] **Step 1: Run a failing pre-change verifier hardening check**
|
||||
|
||||
Run this PowerShell check before editing the verifier:
|
||||
|
||||
```powershell
|
||||
$scriptPath = "scripts\verify-n14-uia-selector-report.ps1"
|
||||
$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8
|
||||
$requiredSnippets = @(
|
||||
'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue',
|
||||
'$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop',
|
||||
'if ($jsonItem.PSIsContainer)',
|
||||
'if ($jsonItem.Length -le 0)',
|
||||
'$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop',
|
||||
'$allowedMatchFields = @(',
|
||||
'$match.PSObject.Properties',
|
||||
'JSON match leaks visible text field'
|
||||
)
|
||||
foreach ($snippet in $requiredSnippets) {
|
||||
if (-not $scriptText.Contains($snippet)) {
|
||||
throw "missing verifier hardening snippet: $snippet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected: FAIL, with the first missing snippet indicating stale-output cleanup is not implemented yet.
|
||||
|
||||
- [ ] **Step 2: Insert stale-output cleanup before CLI execution**
|
||||
|
||||
In `scripts/verify-n14-uia-selector-report.ps1`, after:
|
||||
|
||||
```powershell
|
||||
$jsonPath = Join-Path $outDir "n12r-selector-report.json"
|
||||
$mdPath = Join-Path $outDir "n12r-selector-report.md"
|
||||
```
|
||||
|
||||
insert:
|
||||
|
||||
```powershell
|
||||
Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace output file existence checks with file and non-empty checks**
|
||||
|
||||
Replace this block:
|
||||
|
||||
```powershell
|
||||
if (-not (Test-Path -LiteralPath $jsonPath)) {
|
||||
throw "JSON report was not created: $jsonPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $mdPath)) {
|
||||
throw "Markdown report was not created: $mdPath"
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```powershell
|
||||
$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop
|
||||
if ($jsonItem.PSIsContainer) {
|
||||
throw "JSON report path is not a file: $jsonPath"
|
||||
}
|
||||
if ($jsonItem.Length -le 0) {
|
||||
throw "JSON report is empty: $jsonPath"
|
||||
}
|
||||
$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop
|
||||
if ($mdItem.PSIsContainer) {
|
||||
throw "Markdown report path is not a file: $mdPath"
|
||||
}
|
||||
if ($mdItem.Length -le 0) {
|
||||
throw "Markdown report is empty: $mdPath"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add JSON match-field allowlist validation after report summary checks**
|
||||
|
||||
After this block:
|
||||
|
||||
```powershell
|
||||
if ($report.ambiguous_count -ne 0) {
|
||||
throw "ambiguous_count=$($report.ambiguous_count), want 0"
|
||||
}
|
||||
```
|
||||
|
||||
insert:
|
||||
|
||||
```powershell
|
||||
Write-Host "## JSON match field allowlist"
|
||||
$allowedMatchFields = @(
|
||||
"path",
|
||||
"control_type",
|
||||
"automation_id",
|
||||
"class_name",
|
||||
"framework_id",
|
||||
"has_name",
|
||||
"name_length"
|
||||
)
|
||||
$allowedMatchFieldLookup = @{}
|
||||
foreach ($field in $allowedMatchFields) {
|
||||
$allowedMatchFieldLookup[$field] = $true
|
||||
}
|
||||
foreach ($selector in @($report.selectors)) {
|
||||
foreach ($match in @($selector.matches)) {
|
||||
if ($null -eq $match) {
|
||||
continue
|
||||
}
|
||||
foreach ($property in @($match.PSObject.Properties)) {
|
||||
$fieldName = $property.Name
|
||||
if ($fieldName -eq "name" -or $fieldName -eq "value") {
|
||||
throw "JSON match leaks visible text field: $fieldName"
|
||||
}
|
||||
if (-not $allowedMatchFieldLookup.ContainsKey($fieldName)) {
|
||||
throw "JSON match contains disallowed field: $fieldName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the hardening check again**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$scriptPath = "scripts\verify-n14-uia-selector-report.ps1"
|
||||
$scriptText = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8
|
||||
$requiredSnippets = @(
|
||||
'Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue',
|
||||
'$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop',
|
||||
'if ($jsonItem.PSIsContainer)',
|
||||
'if ($jsonItem.Length -le 0)',
|
||||
'$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop',
|
||||
'$allowedMatchFields = @(',
|
||||
'$match.PSObject.Properties',
|
||||
'JSON match leaks visible text field'
|
||||
)
|
||||
foreach ($snippet in $requiredSnippets) {
|
||||
if (-not $scriptText.Contains($snippet)) {
|
||||
throw "missing verifier hardening snippet: $snippet"
|
||||
}
|
||||
}
|
||||
Write-Host "VERIFIER_HARDENING_SNIPPETS_OK"
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
VERIFIER_HARDENING_SNIPPETS_OK
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the hardened verifier**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
```text
|
||||
## go test ./internal/uiaselector ./cmd/uia-selector-report
|
||||
## go run ./cmd/uia-selector-report
|
||||
N14 selector report generated: catalog=10 matched=10 unmatched=0 ambiguous=0
|
||||
## JSON match field allowlist
|
||||
## output safety scan
|
||||
N14 UIA selector report verification passed.
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit verifier hardening**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add scripts\verify-n14-uia-selector-report.ps1
|
||||
git diff --cached --name-only
|
||||
```
|
||||
|
||||
Expected staged file list:
|
||||
|
||||
```text
|
||||
scripts/verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
Then commit:
|
||||
|
||||
```powershell
|
||||
git commit -m "test: harden N15 selector report verifier"
|
||||
```
|
||||
|
||||
## Task 3: Final verification and boundary check
|
||||
|
||||
**Files:**
|
||||
- No new files.
|
||||
- Verify: `internal/uiaselector/report_test.go`, `scripts/verify-n14-uia-selector-report.ps1`, and unchanged helper/MCP action surface.
|
||||
|
||||
- [ ] **Step 1: Run targeted tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
|
||||
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
|
||||
```
|
||||
|
||||
Expected: both commands PASS.
|
||||
|
||||
- [ ] **Step 2: Run repository tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: all packages PASS.
|
||||
|
||||
If dependency download fails with a transient proxy error, retry once with:
|
||||
|
||||
```powershell
|
||||
$env:GOPROXY = "https://goproxy.cn,direct"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the hardened verifier**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
Expected: verifier PASS and output includes `## JSON match field allowlist`.
|
||||
|
||||
- [ ] **Step 4: Check helper/MCP/live action boundary**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go cmd/uia-selector-report/main.go internal/uiaselector/report.go internal/uiaselector/catalog.go internal/uiaselector/matcher.go
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
If any file appears, stop and inspect because N15 is test/verifier-only.
|
||||
|
||||
- [ ] **Step 5: Check changed files**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --name-only main..HEAD
|
||||
```
|
||||
|
||||
Expected changed files are limited to:
|
||||
|
||||
```text
|
||||
docs/superpowers/specs/2026-07-09-n15-selector-report-hardening-design.md
|
||||
docs/superpowers/plans/2026-07-09-n15-selector-report-hardening-implementation.md
|
||||
internal/uiaselector/report_test.go
|
||||
scripts/verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Final status**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short --branch
|
||||
git log --oneline --max-count=6
|
||||
```
|
||||
|
||||
Expected: working tree clean on `codex/n15-report-hardening`. Do not push unless the user explicitly requests pushing this branch or merging to main.
|
||||
|
||||
## Self-review checklist
|
||||
|
||||
- Spec coverage: ambiguous strict-mode test, stale-output cleanup, file/non-empty checks, JSON match-field allowlist, retained forbidden string scan, and helper/MCP boundary check are all covered.
|
||||
- Scope: only `internal/uiaselector/report_test.go` and `scripts/verify-n14-uia-selector-report.ps1` are implementation targets; design and plan docs are documentation targets.
|
||||
- Boundary: no helper op, no MCP tool, no live hwnd/process behavior, no UI action, no report format change, no CLI argument change.
|
||||
- Verification: final commands include targeted tests, all repository tests, hardened verifier, boundary diff check, and changed-file check.
|
||||
- Commit cadence: test and verifier hardening are separate commits.
|
||||
@@ -0,0 +1,266 @@
|
||||
# N15 Selector Report Hardening Design
|
||||
|
||||
日期:2026-07-09
|
||||
|
||||
## 1. 背景
|
||||
|
||||
N14 已在 `main` 上合并并推送,提供了离线 UIA selector 报告能力:
|
||||
|
||||
- `internal/uiaselector/report.go`:报告模型、catalog report generator、JSON/Markdown renderer。
|
||||
- `cmd/uia-selector-report/main.go`:离线 CLI,只接受 dump 文件路径和输出报告路径。
|
||||
- `scripts/verify-n14-uia-selector-report.ps1`:运行测试、生成报告并做基础安全扫描。
|
||||
|
||||
N14 最终 review 留下两个非阻塞建议:
|
||||
|
||||
1. `StrictExitAmbiguous` 分支缺少显式单测。
|
||||
2. verifier 的输出安全检查仍以字符串扫描为主,并且运行 CLI 前没有清理旧输出文件。
|
||||
|
||||
N15 的目标是把这些 review 建议收口为一个小型加固节点,不扩展产品能力,不接触 live UI,不新增 helper op,不新增 MCP tool,不扩展 live action surface。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
N15 只做报告安全与验证加固:
|
||||
|
||||
1. 增加 `StrictExitAmbiguous` 显式单测,锁定 `ambiguous_count > 0` 时严格模式返回 `3`。
|
||||
2. 加固 `scripts/verify-n14-uia-selector-report.ps1`,避免旧报告掩盖 CLI 输出回归。
|
||||
3. 增加输出文件检查:必须存在、必须是文件、大小必须大于 0。
|
||||
4. 增加 JSON 字段 allowlist 检查,确认 `selectors[].matches[]` 只包含允许的结构字段。
|
||||
5. 保持 N14 报告格式和 CLI 参数不变。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
N15 不做以下事项:
|
||||
|
||||
```text
|
||||
新增 selector
|
||||
修改 selector catalog
|
||||
修改报告 JSON/Markdown 格式
|
||||
新增 CLI 参数
|
||||
新增 helper op
|
||||
新增 MCP tool
|
||||
连接内网 iSphere
|
||||
live dump 当前窗口
|
||||
使用 hwnd/process 参数
|
||||
点击控件
|
||||
输入文本
|
||||
打开会话
|
||||
搜索联系人
|
||||
发送消息
|
||||
发送文件
|
||||
接收文件
|
||||
读取聊天内容
|
||||
导出完整 visible text
|
||||
```
|
||||
|
||||
N15 也不清理本地分支。分支清理可以在 N15 合并后单独执行。
|
||||
|
||||
## 4. 推荐方案
|
||||
|
||||
采用“小范围加固”方案,只修改两个文件:
|
||||
|
||||
```text
|
||||
internal/uiaselector/report_test.go
|
||||
scripts/verify-n14-uia-selector-report.ps1
|
||||
```
|
||||
|
||||
不修改:
|
||||
|
||||
```text
|
||||
native/ISphereWinHelper/Program.cs
|
||||
internal/tools/winhelper.go
|
||||
cmd/isphere-mcp/main.go
|
||||
cmd/uia-selector-report/main.go
|
||||
internal/uiaselector/report.go
|
||||
```
|
||||
|
||||
理由:N14 的实现逻辑已经通过最终验证和 review。N15 只需要补测试和 verifier 防回归能力,不需要改变生产代码路径。
|
||||
|
||||
## 5. Ambiguous strict-mode 测试设计
|
||||
|
||||
在 `internal/uiaselector/report_test.go` 中新增测试:
|
||||
|
||||
```text
|
||||
TestStrictModeClassifiesAmbiguousMatches
|
||||
```
|
||||
|
||||
测试构造一个最小 UIA tree:
|
||||
|
||||
```text
|
||||
root Window frmMain
|
||||
child Pane duplicate
|
||||
child Pane duplicate
|
||||
```
|
||||
|
||||
测试 catalog 只包含一个 read-only selector:
|
||||
|
||||
```text
|
||||
id: duplicate_panel
|
||||
allowed_use: read_only_locate
|
||||
required:
|
||||
automation_id: duplicate
|
||||
control_type: Pane
|
||||
framework_id: WinForm
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
```text
|
||||
report.OK == true
|
||||
report.CatalogSize == 1
|
||||
report.MatchedCount == 1
|
||||
report.UnmatchedCount == 0
|
||||
report.AmbiguousCount == 1
|
||||
report.Selectors[0].MatchCount == 2
|
||||
StrictExitCode(report) == StrictExitAmbiguous
|
||||
```
|
||||
|
||||
该测试只使用内存构造的 `Node`,不依赖 N12R fixture,也不涉及 visible text。
|
||||
|
||||
## 6. Verifier 加固设计
|
||||
|
||||
### 6.1 清理旧输出
|
||||
|
||||
在 `go run ./cmd/uia-selector-report` 之前删除旧报告:
|
||||
|
||||
```powershell
|
||||
Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
目的:如果未来 CLI 回归为“退出 0 但未写新文件”,旧报告不会让后续存在性检查误通过。
|
||||
|
||||
### 6.2 文件存在和非空检查
|
||||
|
||||
替换当前 `Test-Path` 检查为更严格的文件检查:
|
||||
|
||||
```powershell
|
||||
$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop
|
||||
if (-not $jsonItem.PSIsContainer -eq $false) { ... }
|
||||
if ($jsonItem.Length -le 0) { ... }
|
||||
```
|
||||
|
||||
实际实现应避免 PowerShell 运算优先级歧义,推荐使用清晰表达:
|
||||
|
||||
```powershell
|
||||
if ($jsonItem.PSIsContainer) { throw "JSON report path is not a file: $jsonPath" }
|
||||
if ($jsonItem.Length -le 0) { throw "JSON report is empty: $jsonPath" }
|
||||
```
|
||||
|
||||
Markdown 报告同样检查。
|
||||
|
||||
### 6.3 JSON match 字段 allowlist
|
||||
|
||||
解析 JSON 后,对每个 `selectors[].matches[]` 对象检查字段集合,只允许:
|
||||
|
||||
```text
|
||||
path
|
||||
control_type
|
||||
automation_id
|
||||
class_name
|
||||
framework_id
|
||||
has_name
|
||||
name_length
|
||||
```
|
||||
|
||||
禁止出现:
|
||||
|
||||
```text
|
||||
name
|
||||
value
|
||||
text
|
||||
visible_text
|
||||
```
|
||||
|
||||
实现策略:
|
||||
|
||||
1. 遍历 `$report.selectors`。
|
||||
2. 遍历 `$selector.matches`。
|
||||
3. 对每个 match 的 `$match.PSObject.Properties.Name` 做集合比较。
|
||||
4. 遇到不在 allowlist 中的字段立即失败。
|
||||
5. 如果字段名是 `name` 或 `value`,错误信息明确指出这是 visible text 字段泄漏。
|
||||
|
||||
这比单纯扫描字符串更稳,因为它检查的是 JSON 对象结构。
|
||||
|
||||
### 6.4 保留字符串扫描
|
||||
|
||||
保留 N14 已有字符串扫描,用于发现:
|
||||
|
||||
```text
|
||||
"name"
|
||||
"value"
|
||||
国网甘肃省电力公司
|
||||
send_after_approval
|
||||
send_file_after_approval
|
||||
receive_file_after_approval
|
||||
open_conversation
|
||||
InvokePattern
|
||||
ValuePattern.SetValue
|
||||
```
|
||||
|
||||
字段 allowlist 和字符串扫描同时存在:
|
||||
|
||||
- allowlist 负责结构化字段泄漏。
|
||||
- 字符串扫描负责已知敏感文本和动作词防线。
|
||||
|
||||
## 7. 测试与验证策略
|
||||
|
||||
N15 implementation plan 必须采用 TDD。
|
||||
|
||||
最小验证命令:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector -run "TestStrictModeClassifiesAmbiguousMatches" -count=1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
|
||||
go test ./...
|
||||
```
|
||||
|
||||
最终验证命令:
|
||||
|
||||
```powershell
|
||||
go test ./internal/uiaselector ./cmd/uia-selector-report -count=1
|
||||
go test ./...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n14-uia-selector-report.ps1
|
||||
git diff --name-only main..HEAD -- native/ISphereWinHelper/Program.cs internal/tools/winhelper.go cmd/isphere-mcp/main.go
|
||||
```
|
||||
|
||||
最后一条命令期望无输出。
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
### 风险 1:PowerShell 对单元素数组展开造成遍历差异
|
||||
|
||||
缓解:实现遍历时使用 `@($report.selectors)` 和 `@($selector.matches)`,确保单元素和多元素都按数组处理。
|
||||
|
||||
### 风险 2:allowlist 检查误报 PowerShell 内置属性
|
||||
|
||||
缓解:只读取 `$match.PSObject.Properties.Name`,这返回 JSON 对象自身属性,不读取方法或扩展成员。
|
||||
|
||||
### 风险 3:旧输出清理误删非目标文件
|
||||
|
||||
缓解:只删除两个明确计算出的文件路径:
|
||||
|
||||
```text
|
||||
runs/n14-selector-report/n12r-selector-report.json
|
||||
runs/n14-selector-report/n12r-selector-report.md
|
||||
```
|
||||
|
||||
不递归删除目录。
|
||||
|
||||
## 9. 验收条件
|
||||
|
||||
N15 可接受条件:
|
||||
|
||||
1. `StrictExitAmbiguous` 有显式单测。
|
||||
2. verifier 运行 CLI 前删除旧 JSON/Markdown 输出文件。
|
||||
3. verifier 检查 JSON/Markdown 输出存在、是文件、非空。
|
||||
4. verifier 对 JSON `matches[]` 执行字段 allowlist 检查。
|
||||
5. verifier 继续扫描 `"name"`、`"value"`、已知 visible text 和动作词。
|
||||
6. `go test ./...` 通过。
|
||||
7. `scripts\verify-n14-uia-selector-report.ps1` 通过。
|
||||
8. 不修改 helper/MCP/live action 文件。
|
||||
|
||||
## 10. 推荐下一步
|
||||
|
||||
下一步写 N15 implementation plan,拆分为两个小任务:
|
||||
|
||||
1. TDD 增加 ambiguous strict-mode 单测。
|
||||
2. 加固 verifier 的旧输出清理、文件检查和 JSON 字段 allowlist。
|
||||
@@ -141,3 +141,51 @@ func TestMarkdownReportDoesNotExposeVisibleText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictModeClassifiesAmbiguousMatches(t *testing.T) {
|
||||
root := &Node{
|
||||
ControlType: "Window",
|
||||
AutomationID: "frmMain",
|
||||
FrameworkID: "WinForm",
|
||||
Children: []Node{
|
||||
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
|
||||
{ControlType: "Pane", AutomationID: "duplicate", FrameworkID: "WinForm"},
|
||||
},
|
||||
}
|
||||
catalog := []Selector{
|
||||
{
|
||||
ID: "duplicate_panel",
|
||||
Description: "duplicate structural selector",
|
||||
AllowedUse: AllowedUseReadOnlyLocate,
|
||||
Required: Criteria{AutomationID: "duplicate", ControlType: "Pane", FrameworkID: "WinForm"},
|
||||
StabilityScore: 80,
|
||||
Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/duplicate"},
|
||||
},
|
||||
}
|
||||
|
||||
report := RunCatalogReport("synthetic-ambiguous.json", root, catalog, fixedReportTime())
|
||||
if !report.OK {
|
||||
t.Fatalf("report OK=false: %#v", report.Error)
|
||||
}
|
||||
if report.CatalogSize != 1 {
|
||||
t.Fatalf("catalog_size = %d, want 1", report.CatalogSize)
|
||||
}
|
||||
if report.MatchedCount != 1 {
|
||||
t.Fatalf("matched_count = %d, want 1", report.MatchedCount)
|
||||
}
|
||||
if report.UnmatchedCount != 0 {
|
||||
t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount)
|
||||
}
|
||||
if report.AmbiguousCount != 1 {
|
||||
t.Fatalf("ambiguous_count = %d, want 1", report.AmbiguousCount)
|
||||
}
|
||||
if len(report.Selectors) != 1 {
|
||||
t.Fatalf("len(selectors) = %d, want 1", len(report.Selectors))
|
||||
}
|
||||
if got := report.Selectors[0].MatchCount; got != 2 {
|
||||
t.Fatalf("selector match_count = %d, want 2", got)
|
||||
}
|
||||
if got := StrictExitCode(report); got != StrictExitAmbiguous {
|
||||
t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitAmbiguous)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ $outDir = Join-Path $repo "runs\n14-selector-report"
|
||||
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||
$jsonPath = Join-Path $outDir "n12r-selector-report.json"
|
||||
$mdPath = Join-Path $outDir "n12r-selector-report.md"
|
||||
Remove-Item -LiteralPath $jsonPath, $mdPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "## go run ./cmd/uia-selector-report"
|
||||
go run ./cmd/uia-selector-report `
|
||||
@@ -29,11 +30,19 @@ if ($LASTEXITCODE -ne 0) {
|
||||
throw "uia-selector-report failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $jsonPath)) {
|
||||
throw "JSON report was not created: $jsonPath"
|
||||
$jsonItem = Get-Item -LiteralPath $jsonPath -ErrorAction Stop
|
||||
if ($jsonItem.PSIsContainer) {
|
||||
throw "JSON report path is not a file: $jsonPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $mdPath)) {
|
||||
throw "Markdown report was not created: $mdPath"
|
||||
if ($jsonItem.Length -le 0) {
|
||||
throw "JSON report is empty: $jsonPath"
|
||||
}
|
||||
$mdItem = Get-Item -LiteralPath $mdPath -ErrorAction Stop
|
||||
if ($mdItem.PSIsContainer) {
|
||||
throw "Markdown report path is not a file: $mdPath"
|
||||
}
|
||||
if ($mdItem.Length -le 0) {
|
||||
throw "Markdown report is empty: $mdPath"
|
||||
}
|
||||
|
||||
$report = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
@@ -53,6 +62,37 @@ if ($report.ambiguous_count -ne 0) {
|
||||
throw "ambiguous_count=$($report.ambiguous_count), want 0"
|
||||
}
|
||||
|
||||
Write-Host "## JSON match field allowlist"
|
||||
$allowedMatchFields = @(
|
||||
"path",
|
||||
"control_type",
|
||||
"automation_id",
|
||||
"class_name",
|
||||
"framework_id",
|
||||
"has_name",
|
||||
"name_length"
|
||||
)
|
||||
$allowedMatchFieldLookup = @{}
|
||||
foreach ($field in $allowedMatchFields) {
|
||||
$allowedMatchFieldLookup[$field] = $true
|
||||
}
|
||||
foreach ($selector in @($report.selectors)) {
|
||||
foreach ($match in @($selector.matches)) {
|
||||
if ($null -eq $match) {
|
||||
continue
|
||||
}
|
||||
foreach ($property in @($match.PSObject.Properties)) {
|
||||
$fieldName = $property.Name
|
||||
if ($fieldName -eq "name" -or $fieldName -eq "value") {
|
||||
throw "JSON match leaks visible text field: $fieldName"
|
||||
}
|
||||
if (-not $allowedMatchFieldLookup.ContainsKey($fieldName)) {
|
||||
throw "JSON match contains disallowed field: $fieldName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "## output safety scan"
|
||||
$jsonRaw = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8
|
||||
$mdRaw = Get-Content -LiteralPath $mdPath -Raw -Encoding UTF8
|
||||
|
||||
Reference in New Issue
Block a user