13 Commits

Author SHA1 Message Date
石头
cfe2ce5d64 chore: ignore temp intake directory 2026-07-09 15:22:23 +08:00
石头
d8dabada57 test: add N13 selector verifier 2026-07-09 15:10:28 +08:00
石头
27c87d318d feat: match N13 UIA selectors offline 2026-07-09 15:10:01 +08:00
石头
4d5a9b0ae8 feat: load N13 UIA dump fixture 2026-07-09 15:09:00 +08:00
石头
d648c9476d feat: add N13 UIA selector catalog 2026-07-09 15:06:02 +08:00
石头
a7b41ccb15 test: add N13 redacted UIA fixture 2026-07-09 15:00:36 +08:00
石头
5b5d33140b docs: plan N13 UIA selector catalog implementation 2026-07-09 14:57:51 +08:00
石头
b2b1c0c968 docs: design N13 UIA selector catalog 2026-07-09 14:52:13 +08:00
石头
19d05fe3c9 fix: avoid N12R process-list boundary false positive 2026-07-09 14:45:14 +08:00
石头
55c749b0ab docs: add N12R operator retry steps 2026-07-09 10:41:20 +08:00
石头
22e4e8f8ce chore: ignore local handoff prompt 2026-07-08 16:43:06 +08:00
石头
08516ed35a docs: translate internal sandbox runbook to Chinese 2026-07-08 16:40:36 +08:00
石头
c54f7c447a docs: add N12R internal sandbox handoff workflow 2026-07-08 16:08:10 +08:00
22 changed files with 4519 additions and 49 deletions

6
.gitignore vendored
View File

@@ -18,3 +18,9 @@ obj/
dist/
__pycache__/
.venv/
# Local handoff prompt
/给接手人的交接话术.md
# Local intake scratch directory
/temp/

View File

@@ -26,9 +26,15 @@ N0 Baseline cleanup and direction lock
-> N10 Operator runbook and process scripts
-> N11 Commit/review checkpoint
-> N12-pre Offline evidence intake
-> N12 Real logged-in UIA capture gate
-> N12R Internal-sandbox live UIA capture route
-> N12R-review Selector design review gate
Blocked/non-active:
-> N12-current Current-environment real logged-in UIA capture gate
```
**Reality update 2026-07-08:** iSphere is internal-network only. The current repository environment is an outer-network coordination/analysis environment. Therefore `N12-current` is not the active route unless network placement changes. Live login and live UIA capture must happen in an internal-network test sandbox through `N12R`.
Hard rule: **no node may start until the previous node's acceptance conditions are met.**
---
@@ -753,7 +759,7 @@ Pass if:
- Unrelated generated files are staged.
- Worktree contains ignored build outputs outside `runs/`.
**Next node:** N12-pre if the current environment cannot log in; otherwise N12 may start only when the real logged-in current-environment preconditions below are met.
**Next node:** N12-pre for offline evidence intake, followed by N12R for live capture in an internal-network test sandbox. `N12-current` remains blocked because the current outer-network environment cannot log in to internal iSphere.
---
@@ -830,24 +836,101 @@ Pass if:
- Package is used to design automatic login, sending, file transfer, injection, hook, memory reading, or endpoint-security bypass.
- Any reader treats the offline package as a replacement for true N12.
**Next node:** N12 remains blocked until the current environment can manually log in and perform a live current-environment UIA capture.
**Next node:** N12R internal-sandbox live capture route if live UI evidence is needed. `N12-current` remains blocked in the outer-network environment.
---
## N12: Real logged-in UIA capture gate
## N12R: Internal-sandbox live capture route
**Purpose:** Capture real iSphere UIA evidence after the user provides/login context.
**Purpose:** Capture live iSphere UIA evidence from a separate internal-network test sandbox where iSphere can be manually opened and logged in.
**Preconditions:**
- N11 accepted.
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as N12 pass evidence.
- User manually opens and logs in to iSphere.
- iSphere main window is visible.
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as live capture evidence.
- An internal-network test sandbox can manually open and log in to iSphere / IMPlatformClient using the normal approved process.
- The internal sandbox has this repository or a trusted copy of the built helper/MCP artifacts.
- The operator follows `docs/internal-sandbox-operator-runbook.md`.
**Actions:**
Use Go MCP tools:
- In the internal sandbox, run only the first-phase read-only operations:
```text
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
or the equivalent helper ops:
```text
version
self_check
scan_windows
dump_uia
```
- Save the returned package under:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
- Follow the package contract in `docs/internal-sandbox-live-capture-plan.md`.
**Expected outputs:**
- `metadata/source-notes.txt` stating internal-network test sandbox, manual login, main window visible, and forbidden actions not performed.
- `helper/version.json`.
- `helper/self-check.json`.
- `windows/scan-windows.json`.
- `windows/selected-window.json`.
- `uia/dump-uia-main.json`.
- `uia/dump-uia-main-redacted.json`.
- `notes/operator-notes.txt`.
**Acceptance conditions:**
Pass if:
- The package satisfies `docs/n12r-return-package-validation-checklist.md`.
- `scan-windows.json` includes an iSphere / IMPlatformClient candidate window.
- `dump-uia-main.json` contains a UI Automation root tree for the selected window.
- The capture notes state the user manually logged in and no forbidden action was performed.
- The redacted dump exists before broad sharing.
**Stop conditions:**
- Internal sandbox cannot manually log in.
- No candidate window appears.
- UIA tree is empty or inaccessible.
- Package contains secrets or message contents that require redaction before review.
- Any step requires automatic login, clicking into conversations, sending, uploading, downloading, injection, hook, memory reading, or endpoint-security bypass.
**Next node:** N12R-review selector design review gate. Do not implement selectors until N12R evidence is accepted and a separate selector design plan is approved.
---
## N12-current: Current-environment real logged-in UIA capture gate (blocked)
This is the original N12 definition. It is retained as a blocked/non-active gate for historical clarity. It is not the active route while the repository environment is outside the internal network.
**Blocked reason:** iSphere is internal-network only, and the current repository environment cannot manually log in.
**Original purpose:** Capture real iSphere UIA evidence from a manually logged-in iSphere window in the same environment where this repository is running.
**Original preconditions, if network placement changes later:**
- N11 accepted.
- Optional N12-pre offline evidence, if used, has been validated only as pre-gate context and not as N12 pass evidence.
- The repository environment can manually open and log in to iSphere / IMPlatformClient using the normal approved process.
- iSphere main window is visible in that same environment.
**Original allowed actions:**
Use only these Go MCP tools:
```text
win_helper_scan_windows
@@ -860,35 +943,9 @@ Save output under:
runs/real-loggedin-lab/uia-dumps/
```
**Expected outputs:**
**Current decision:** Do not wait on this gate as the active route. Use N12R internal-sandbox live capture instead.
- One or more real iSphere UIA dump JSON files.
- Window metadata showing process name/title/handle.
**Acceptance conditions:**
Pass if:
- Candidate iSphere/IMPlatform window is found.
- UIA root node is a window/control tree.
- Dump includes enough fields to identify controls:
- `name`
- `control_type`
- `automation_id`
- `class_name`
- `children`
- No clicking, typing, sending, uploading, receiving, or downloading occurs.
- No passwords/tokens are captured.
**Stop conditions:**
- User is not logged in.
- No candidate window appears.
- UIA tree is empty or inaccessible.
- Captured data contains sensitive secrets that need redaction before reporting.
- Current environment cannot log in; in that case, use only N12-pre offline analysis and do not mark N12 passed.
**Next node:** Future selector design. Do not implement selectors until N12 passes.
**Next node:** Future selector design may be considered only after N12R evidence is accepted, or after N12-current becomes possible and passes. Do not implement selectors before a separate selector design plan is approved.
---
@@ -897,7 +954,7 @@ Pass if:
Every node must satisfy these rules:
1. No Python MCP code.
2. No real send/search/file action before N12.
2. No real send/search/file action before an explicitly approved post-N12R selector/action plan.
3. No process injection, hook, credential extraction, token extraction, or memory reading.
4. No broad `git add -A`; stage exact paths.
5. Every code node uses tests first.
@@ -911,13 +968,13 @@ Every node must satisfy these rules:
Current guidance after N0-N11 completion is:
```text
N12-pre if the current environment cannot log in; N12 only after current-environment manual login is possible.
N12-pre has been used for offline evidence. Because the current environment is outside the internal network, the active live-capture route is N12R internal-sandbox live capture. N12-current remains blocked unless network placement changes.
```
Immediate next action:
```text
If login is unavailable here, collect/validate the full sandbox artifact bundle described in docs/offline-evidence-intake-plan.md. Minimal manifest/scan/dump JSON is optional and only for advanced operators.
If live UI evidence is needed, use `docs/internal-sandbox-live-capture-plan.md` and `docs/internal-sandbox-operator-runbook.md` to collect an N12R package from an internal-network test sandbox. Continue to use `docs/offline-evidence-intake-plan.md` only for offline file evidence.
```
Do not treat offline evidence as N12 pass. Do not start selector/action design until true N12 passes in the current environment.
Do not treat offline evidence as N12 pass. Do not start selector/action design until N12R live evidence is accepted, or until N12-current becomes possible and passes in the current environment.

View File

@@ -211,19 +211,42 @@ Procedure:
During this procedure, the operator must not click, type, send, upload, download, modify client state, or capture passwords/tokens.
## 10. If this environment cannot log in
## 10. If this outer-network environment cannot log in
Do not run or mark the real-login UIA capture gate as passed.
Do not run or mark the original current-environment real-login UIA capture gate as passed.
If login is only possible in another test sandbox, follow `docs/offline-evidence-intake-plan.md` and use the recommended **Full sandbox artifact bundle** route. The user does not need to generate JSON. Ask them to copy the logged-in test sandbox's install directory, user-data directories, ProgramData directories, shortcuts, `%TEMP%\importal` / `localcache` candidates, and simple copy notes into `runs\offline-evidence-intake\<evidence-id>\`.
iSphere is internal-network only. If the current repository environment is outside that network, use two separate routes:
The minimal `manifest.json` / `scan_windows-redacted.json` / `dump_uia-main-redacted.json` route is optional and only for advanced operators. In either route, the package is `N12-pre` context only. It can support offline inventory and reverse analysis, but it does not replace live current-environment `win_helper_scan_windows` and `win_helper_dump_uia` evidence.
1. **N12-pre offline evidence** for copied files and static analysis. Follow `docs/offline-evidence-intake-plan.md` and place packages under `runs\offline-evidence-intake\<evidence-id>\`.
2. **N12R internal-sandbox live capture** for real logged-in UIA evidence. Follow `docs/internal-sandbox-live-capture-plan.md` and `docs/internal-sandbox-operator-runbook.md`; place returned packages under `runs\internal-sandbox-live-capture\<capture-id>\`.
After receiving a full bundle, first do read-only inventory. Do not directly run unknown copied binaries, do not modify original copied files, do not use copied credential-like material to log in, and do not design automatic login, send message, send file, receive file, injection, hook, memory reading, or endpoint-security bypass behavior.
N12R is the active live-capture route when login is only possible in an internal-network test sandbox. It still allows only the four first-phase read-only tools:
```text
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
After receiving an N12R package, validate it with `docs/n12r-return-package-validation-checklist.md` before using it as evidence for selector-design review. Prefer the scripted read-only validator:
```powershell
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
$reportRoot = "runs\internal-sandbox-live-capture-reports"
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot $captureRoot `
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
```
Keep the validation report outside `$captureRoot`; the returned package is evidence input and should remain unchanged.
Neither offline evidence nor N12R authorizes automatic login, search contacts, open conversation, send message, send file, receive file, injection, hook, memory reading, credential extraction, or endpoint-security bypass behavior.
## 11. Troubleshooting
- If C# helper build fails, run `scripts\build-win-helper.ps1` directly and check for missing .NET Framework reference assemblies.
- If `win_helper_version` fails, rerun `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1` first.
- If Go MCP verification fails, rerun `go test ./...` and `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1`.
- If a client cannot start the MCP server, confirm the configured command points to the built `.exe` and the working directory is the repository root.
- If a client cannot start the MCP server, confirm the configured command points to the built `.exe` and the working directory is the repository root.

View File

@@ -378,4 +378,69 @@ Only after that should the team decide whether to:
2. Ask for a better evidence package.
3. Wait until current-environment live login becomes possible.
4. Re-plan true N12.
```
```
---
## 12. Reality update - 2026-07-08
The current repository environment is an outer-network coordination and analysis environment. iSphere / IMPlatformClient is internal-network only, so manual login cannot happen in the current environment.
Do not keep waiting for current-environment login as the active path. The active live-capture route is now:
```text
N12R: Internal-sandbox live capture route
```
New operator documents:
```text
docs/internal-sandbox-live-capture-plan.md
Defines N12R node sequence, package contract, acceptance conditions, and boundaries.
docs/n12r-final-handoff-checklist.md
Short final handoff checklist for internal-sandbox work, outer-network validation, boundaries, and next decision.
docs/internal-sandbox-operator-runbook.md
Step-by-step instructions for the internal-network sandbox operator.
docs/n12r-return-package-validation-checklist.md
Outer-network validation checklist for returned N12R packages.
scripts/validate-n12r-return-package.ps1
One-command read-only validator for returned N12R packages. It writes validation reports outside the returned package root.
scripts/test-validate-n12r-return-package.ps1
Local regression tests for the N12R package validator.
```
N12R package destination:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
Suggested outer-network validation command after a package returns:
```powershell
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
$reportRoot = "runs\internal-sandbox-live-capture-reports"
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot $captureRoot `
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
```
N12-pre offline evidence remains useful for static analysis, but it does not replace N12R live UIA capture. Original current-environment N12 remains blocked unless the repository environment is moved into the internal network and can manually log in.
The only allowed live operations in N12R remain:
```text
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
Do not implement selector/action tools until N12R evidence is accepted and a separate selector design plan is approved.

View File

@@ -0,0 +1,217 @@
# Internal Sandbox Live Capture Plan
## 1. Purpose
This document replaces the unrealistic assumption that the current outer-network repository environment can log in to iSphere.
iSphere is an internal-network application. The current environment is an outer-network coordination and analysis environment. Therefore, live login and live UI Automation capture must happen in a separate internal-network test sandbox where the operator can manually open and log in to iSphere.
This path is named:
```text
N12R: Internal-sandbox live capture route
```
`N12R` is a practical replacement route for collecting live read-only UI evidence from the internal sandbox. It does not mean the original current-environment N12 gate has passed.
## 2. Environment roles
| Environment | Role | Allowed work |
| --- | --- | --- |
| Current outer-network repo environment | Coordination, planning, static/offline evidence analysis, report validation | Read docs, inspect copied evidence packages, generate reports, validate returned capture packages |
| Internal-network test sandbox | Manual iSphere login and live read-only capture | Build/verify helper, manually log in, run only read-only scan/dump capture, package results |
## 3. Current node state
Completed before this plan:
```text
N0-N11 First Go MCP / C# helper phase completed
N12-pre Offline evidence intake and reports for copied package zyl completed
```
Original true N12 remains blocked in the current environment because this environment cannot reach or log in to internal iSphere.
New route:
```text
N12R-0 Prepare internal-sandbox runbook and capture package contract
N12R-1 Internal sandbox verifies helper and Go MCP readiness
N12R-2 Operator manually opens and logs in to iSphere in the internal sandbox
N12R-3 Operator performs read-only live window scan and UIA dump
N12R-4 Operator packages redacted live capture outputs
N12R-5 Outer-network environment validates returned package
N12R-6 Review gate: decide whether selector design may start
```
No node may be skipped. Offline evidence never replaces live capture evidence.
## 4. Hard boundaries
The internal sandbox route only allows read-only evidence capture.
Allowed operations:
```text
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
Equivalent C# helper operations are also allowed only for capture fallback:
```text
version
self_check
scan_windows
dump_uia
```
Forbidden operations and designs:
```text
search contacts
open conversation
send message
send file
receive/download file
automatic login
using copied credentials
credential/token/cookie extraction
process injection
hook
memory reading
endpoint-security bypass/evasion
stealth/persistence
modifying the iSphere client
running copied executables from offline evidence packages
```
The operator must use normal manual login only.
## 5. N12R package contract
Returned live capture packages should be placed in this repository under:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
Recommended package shape:
```text
runs/internal-sandbox-live-capture/<capture-id>/
metadata/
source-notes.txt
os-version.txt
process-list.txt
repo-status.txt
verification-summary.txt
helper/
version.json
self-check.json
windows/
scan-windows.json
selected-window.json
uia/
dump-uia-main.json
dump-uia-main-redacted.json
notes/
operator-notes.txt
```
Optional files:
```text
helper/
verify-win-helper-output.txt
mcp/
verify-go-mcp-output.txt
mcp-client-config.txt
screenshots/
main-window-redacted.png
```
Do not include passwords, tokens, cookies, private keys, full message bodies, or unrelated business documents.
## 6. Required package notes
`metadata/source-notes.txt` must include:
```text
capture_id: <capture-id>
source_environment: internal-network test sandbox
operator: <name or role>
capture_time_local: <YYYY-MM-DD HH:mm:ss timezone>
iSphere manually logged in: yes
client opened before capture: yes
main window visible: yes
operations used: version, self_check, scan_windows, dump_uia
forbidden actions performed: no
this package is N12R live evidence, not current-environment N12 pass
```
`notes/operator-notes.txt` should include:
```text
What client was opened: <iSphere / IMPlatformClient>
How login was performed: manual normal company process
Any MFA/SSO used: yes/no, no secrets included
Selected window reason: <title/process/class/score>
Redactions applied: <brief list>
Any capture problems: <brief list>
```
## 7. Acceptance conditions for N12R
N12R package validation passes only if all are true:
1. Package is under `runs/internal-sandbox-live-capture/<capture-id>/`.
2. `metadata/source-notes.txt` exists and states this came from an internal-network test sandbox.
3. Notes state iSphere was manually logged in and the main window was visible.
4. `helper/version.json` exists and reports `ISphereWinHelper`.
5. `helper/self-check.json` exists and indicates desktop/UIA availability.
6. `windows/scan-windows.json` exists and includes an iSphere / IMPlatformClient candidate window.
7. `windows/selected-window.json` records the selected candidate window metadata.
8. `uia/dump-uia-main.json` exists and contains a UI Automation root tree.
9. Redacted copy `uia/dump-uia-main-redacted.json` exists before broad sharing.
10. No evidence suggests clicking, typing, sending, uploading, downloading, automatic login, injection, hook, memory reading, or bypass behavior.
## 8. What N12R does not authorize
N12R does not authorize selector implementation or action tools by itself. It only provides live UI structure evidence.
After N12R package validation, the next step is a review gate:
```text
Review whether selector design may start.
```
Selector design, if approved later, must begin as a separate written plan. It must still avoid message sending, file transfer, automatic login, injection, hook, memory reading, and bypass behavior unless an explicit later plan changes the project boundary.
## 9. Relationship to existing offline evidence
Existing package:
```text
runs/offline-evidence-intake/zyl/
```
Existing reports:
```text
runs/offline-evidence-intake-reports/zyl/
```
Those reports remain useful for:
```text
application inventory
local data discovery
static evidence analysis
MsgLib format hypotheses
timeline and redaction planning
```
They do not replace N12R live UIA capture. Offline evidence is context only.

View File

@@ -0,0 +1,387 @@
# 内网沙盒操作员运行手册
本手册供能够访问内网测试沙盒的操作员使用;该沙盒中可以人工打开并登录 iSphere / IMPlatformClient。
目标是为外网协调环境生成一个“已登录状态下的只读现场采集包”。
## 1. 安全边界
只允许执行以下动作:
```text
人工打开 iSphere / IMPlatformClient
人工正常登录
保持主窗口可见
运行 version
运行 self_check
运行 scan_windows
运行 dump_uia
把 JSON / 文本文档输出复制进采集包
```
禁止执行以下动作:
```text
自动登录
搜索联系人
打开会话
发送消息
发送文件
接收/下载文件
使用复制出来的凭据
提取密码/token/cookie/私钥
注入进程
hook
读取目标进程内存
绕过终端安全/安防
运行离线证据包中复制出来的可执行文件
```
## 2. 在内网沙盒中准备仓库
在仓库根目录打开 PowerShell。
示例:
```powershell
cd E:\isphere-ai-bridge
```
如果仓库路径不同,请使用实际路径。下面所有命令都假设当前目录是仓库根目录。
检查仓库状态:
```powershell
git status --short --branch
```
建议稍后把该输出记录到采集包中,文件名为:
```text
metadata/repo-status.txt
```
## 3. 验证 helper 和 Go MCP 准备情况
运行 helper 验证:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
```
期望的高层结果:
```text
ok=true
helper version=0.1.0
uia_available=true 或有效布尔值
```
如果内网沙盒已安装 Go也运行
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
```
期望的高层结果:
```text
ok=true
tool_count=4
helper_name=ISphereWinHelper
action_tools_present=false
```
如果没有 Go不要在采集窗口内临时安装无关工具除非操作员已获得批准。下面的 C# helper 兜底流程足以采集同样四个只读 helper 操作。
## 4. 创建采集目录
选择一个采集编号。推荐格式:
```text
YYYY-MM-DD-internal-sandbox-<short-name>
```
示例:
```powershell
$captureId = "2026-07-08-internal-sandbox-a"
$captureRoot = "runs\internal-sandbox-live-capture\$captureId"
New-Item -ItemType Directory -Force -Path `
"$captureRoot\metadata", `
"$captureRoot\helper", `
"$captureRoot\windows", `
"$captureRoot\uia", `
"$captureRoot\notes" | Out-Null
```
## 5. 记录元数据
记录操作系统版本:
```powershell
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber, CsUserName |
Format-List | Out-File -FilePath "$captureRoot\metadata\os-version.txt" -Encoding UTF8
```
记录进程列表:
```powershell
Get-Process | Sort-Object ProcessName |
Select-Object ProcessName, Id, MainWindowTitle, Path |
Format-Table -AutoSize | Out-File -FilePath "$captureRoot\metadata\process-list.txt" -Encoding UTF8
```
记录仓库状态:
```powershell
git status --short --branch | Out-File -FilePath "$captureRoot\metadata\repo-status.txt" -Encoding UTF8
```
创建来源说明:
```powershell
@"
capture_id: $captureId
source_environment:
operator: <>
capture_time_local: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz")
iSphere manually logged in: /
client opened before capture: /
main window visible: /
operations used: version, self_check, scan_windows, dump_uia
forbidden actions performed: no
this package is N12R live evidence, not current-environment N12 pass
"@ | Set-Content -Path "$captureRoot\metadata\source-notes.txt" -Encoding UTF8
```
打包前,请把 `是/否` 字段改成真实情况。
## 6. 人工登录
1. 使用内网沙盒的正常方式打开 iSphere / IMPlatformClient。
2. 按公司正常流程人工登录。
3. 如需 SSO/MFA请正常完成。
4. 不要使用复制出来的凭据,也不要使用离线证据文件。
5. 保持 iSphere 主窗口可见,不要最小化。
除非正常登录流程本身需要,否则不要点击联系人或会话。不要发送或接收任何内容。
## 7. 设置 helper 路径变量
运行 `verify-win-helper.ps1`helper 应位于:
```text
runs\win-helper\ISphereWinHelper.exe
```
设置变量:
```powershell
$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe"
```
## 8. 采集 helper 版本
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-version"
op = "version"
timeout_ms = 5000
args = @{}
} | ConvertTo-Json -Depth 8 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\helper\version.json" -Encoding UTF8
```
## 9. 采集 self_check
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-self-check"
op = "self_check"
timeout_ms = 5000
args = @{}
} | ConvertTo-Json -Depth 8 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\helper\self-check.json" -Encoding UTF8
```
## 10. 扫描可见窗口
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-scan-windows"
op = "scan_windows"
timeout_ms = 5000
args = @{ include_all_visible = $true }
} | ConvertTo-Json -Depth 8 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8
```
打开扫描结果,根据进程名、标题、类名、可见性、边界和评分识别最可能的 iSphere / IMPlatformClient 窗口:
```powershell
Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json |
Select-Object -ExpandProperty data |
Select-Object -ExpandProperty windows |
Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } |
Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize
```
## 11. 保存所选窗口元数据
`<HWND>` 替换为扫描输出中的目标窗口句柄,例如 `0x001A0B2C`
```powershell
$selectedHwnd = "<HWND>"
$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json
$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1
if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" }
$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8
```
## 12. 采集 UIA dump
先使用保守限制:
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-dump-uia-main"
op = "dump_uia"
timeout_ms = 15000
args = @{
hwnd = $selectedHwnd
max_depth = 8
include_text = $true
max_children = 100
}
} | ConvertTo-Json -Depth 12 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8
```
如果 dump 过大或被截断,可以调整限制后再重复一次,并另存为附加文件,例如:
```text
uia/dump-uia-main-depth10-children150.json
```
采集期间不要点击、输入、发送、上传、下载或修改 iSphere 客户端状态。
## 13. 创建脱敏 UIA 副本
创建用于共享的副本:
```powershell
Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force
```
然后人工检查 `dump-uia-main-redacted.json`,在广泛共享前脱敏可见敏感文本。
需要脱敏:
```text
人员姓名
电话号码
邮箱
消息文本
联系人名称
组织敏感内容
类似凭据/token 的字符串
敏感业务文档标题
```
尽量保留结构性 selector 字段:
```text
control_type
automation_id
class_name
framework_id
bounds
is_enabled
is_offscreen
children shape
```
## 14. 写操作员备注
```powershell
@"
: <iSphere / IMPlatformClient>
:
使 MFA/SSO: /
hwnd: $selectedHwnd
: <///>
: <>
: <>
forbidden actions performed: no
"@ | Set-Content -Path "$captureRoot\notes\operator-notes.txt" -Encoding UTF8
```
## 15. 复制出去前验证采集包
运行:
```powershell
$required = @(
"$captureRoot\metadata\source-notes.txt",
"$captureRoot\helper\version.json",
"$captureRoot\helper\self-check.json",
"$captureRoot\windows\scan-windows.json",
"$captureRoot\windows\selected-window.json",
"$captureRoot\uia\dump-uia-main.json",
"$captureRoot\uia\dump-uia-main-redacted.json",
"$captureRoot\notes\operator-notes.txt"
)
foreach ($path in $required) {
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" }
}
"N12R package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt"
```
检查 JSON 是否可解析:
```powershell
Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
```
## 16. 打包传输
压缩采集目录:
```powershell
Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force
```
把目录或 zip 复制回外网仓库环境,并放到:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
## 17. 回报内容
告知协调会话:
```text
N12R 采集包已准备好。
路径: runs/internal-sandbox-live-capture/<capture-id>/
人工 iSphere 登录: 是
采集期间主窗口可见: 是
forbidden actions performed: no
```
随后由协调会话验证返回包。不要把原始当前环境 N12 标记为通过。

View File

@@ -0,0 +1,219 @@
# N12R Final Handoff Checklist
Date: 2026-07-08
This is the short handoff entry for the current reality:
- The current repository environment is an outer-network coordination and analysis environment.
- iSphere / IMPlatformClient is internal-network only.
- Do not treat the current environment as capable of completing live login capture.
- The active live-capture route is `N12R: Internal-sandbox live capture route`.
- The original current-environment live-login gate remains blocked unless the repository environment itself is moved into the internal network and an operator can manually log in there.
## 1. Read this first
Use these files in this order:
```text
docs/handoff-2026-07-06.md
docs/n12r-final-handoff-checklist.md
docs/internal-sandbox-live-capture-plan.md
docs/internal-sandbox-operator-runbook.md
docs/n12r-return-package-validation-checklist.md
```
If you only need the immediate next action, follow section 3 and section 4 below.
## 2. Current node map
```text
N0-N11
Completed first-phase Go MCP / WinHelper foundation.
N12-pre
Offline evidence intake and static analysis.
Evidence input: runs/offline-evidence-intake/zyl
Reports: runs/offline-evidence-intake-reports/zyl
Status: useful supporting analysis, not a substitute for live UIA capture.
N12-current
Current-environment real logged-in UIA capture.
Status: blocked in this outer-network environment.
N12R
Internal-sandbox live capture route.
Status: active live-capture route.
N13
Selector/UIA design review.
Status: do not start until an N12R package is accepted.
```
## 3. What the internal-network sandbox operator must do
The internal operator must use:
```text
docs/internal-sandbox-operator-runbook.md
```
Required operator flow:
1. Prepare the repository inside an internal-network test sandbox.
2. Start iSphere / IMPlatformClient normally.
3. Complete manual interactive login using the normal company process.
4. Keep the main iSphere window visible.
5. Run only read-only helper checks:
- `version`
- `self_check`
- `scan_windows`
- `dump_uia`
6. Save the returned package under:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
Required returned package files:
```text
metadata/source-notes.txt
helper/version.json
helper/self-check.json
windows/scan-windows.json
windows/selected-window.json
uia/dump-uia-main.json
uia/dump-uia-main-redacted.json
notes/operator-notes.txt
```
Recommended returned package files:
```text
metadata/os-version.txt
metadata/process-list.txt
metadata/repo-status.txt
metadata/verification-summary.txt
```
## 4. What the outer-network receiver must do
After the internal sandbox returns a package, place it at:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
Then run the read-only validator:
```powershell
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
$reportRoot = "runs\internal-sandbox-live-capture-reports"
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot $captureRoot `
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
```
Keep validation reports outside `$captureRoot`. The returned package is evidence input and should remain unchanged.
Run the validator regression tests before changing the validator:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-validate-n12r-return-package.ps1
```
## 5. Acceptance gate for N12R
Accept an N12R package only if all of these are true:
1. Required files exist.
2. Required JSON files parse.
3. `helper/version.json` reports `ISphereWinHelper`.
4. `helper/self-check.json` reports `ok=true` and UIA availability.
5. `windows/scan-windows.json` includes an iSphere / IMPlatformClient candidate window.
6. `windows/selected-window.json` records the selected hwnd and title or process metadata.
7. `uia/dump-uia-main.json` contains a parseable UIA root/tree.
8. `uia/dump-uia-main-redacted.json` exists and parses.
9. Operator notes state manual login and no forbidden action.
10. The validator decision is `ACCEPT`.
If any acceptance item fails, keep N12R open and request a corrected internal-sandbox capture.
## 6. Hard boundary
Allowed live operations remain only:
```text
win_helper_version
win_helper_self_check
win_helper_scan_windows
win_helper_dump_uia
```
Do not add or use any tool for:
```text
automatic login
search contacts
open conversation
send message
send file
receive file
upload file
download file
credential extraction
injection
hooking
memory reading
endpoint-security bypass
```
Do not run copied client binaries from an evidence package.
## 7. Offline evidence status
Offline evidence already received:
```text
runs/offline-evidence-intake/zyl
```
Generated reports are outside the original evidence package:
```text
runs/offline-evidence-intake-reports/zyl
```
Important offline conclusions:
- Two IMPlatformClient builds were observed: `4.1.2.6842` and `4.1.2.6839`.
- `MsgLib.db` artifacts are not standard SQLite.
- Static .NET metadata and logs support format/owner hypotheses.
- Offline evidence is useful for context, but it cannot prove live UIA selector behavior.
## 8. Environment caveats
Known local caveat:
```text
go is not installed in the current outer-network environment
```
This affects `scripts/verify-go-mcp.ps1` in this environment only. It does not change the N12R route.
## 9. Next decision after N12R acceptance
If the returned N12R package is accepted:
1. Record the validation report path.
2. Review the UIA tree structure.
3. Decide whether selector design is stable enough for N13.
4. Write a separate N13 selector design plan before adding any selector logic.
If the returned N12R package is rejected:
1. Keep N12R as active.
2. Send the validation failure list back to the internal-network operator.
3. Request a corrected read-only capture.

View File

@@ -0,0 +1,341 @@
# N12R 内网操作员最小重做步骤
日期2026-07-09
适用对象:内网测试沙盒操作员。
目的:修正本次 N12R 返回包中 `selected-window.json` 缺失和 `dump_uia` 失败的问题,重新生成一个可被外网校验接受的 N12R 采集包。
## 1. 本次失败原因
本次返回包被外网校验拒收,核心原因是窗口句柄写错了。
日志中写成了:
```powershell
$selectedHwnd = "<0x290E6E>"
```
这是错误写法。`<HWND>` 里的尖括号只是提示格式,实际填写时必须删掉。
正确写法是:
```powershell
$selectedHwnd = "0x290E6E"
```
因为多了尖括号,后续窗口匹配失败:
```text
selected hwnd not found in scan result: <0x290E6E>
```
所以:
1. `windows/selected-window.json` 没有生成。
2. `dump_uia` 使用了无效 hwnd。
3. `uia/dump-uia-main.json` 返回 `WINDOW_NOT_FOUND`
## 2. 重做前确认
在内网测试沙盒中打开 PowerShell进入仓库根目录
```powershell
cd E:\isphere-ai-bridge
```
确认 iSphere / IMPlatformClient 已经人工登录,并且主窗口可见、没有最小化。
不要执行以下动作:
```text
搜索联系人
打开会话
发送消息
发送文件
接收/下载文件
自动登录
注入
hook
读取内存
绕过安防
```
## 3. 推荐做法:重新运行 scan_windows
窗口句柄可能已经变化,所以推荐先重新扫描窗口。
如果沿用原采集编号:
```powershell
$captureId = "2026-07-08-internal-sandbox-a"
$captureRoot = "runs\internal-sandbox-live-capture\$captureId"
$helper = Resolve-Path -LiteralPath "runs\win-helper\ISphereWinHelper.exe"
```
重新扫描可见窗口:
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-scan-windows-retry"
op = "scan_windows"
timeout_ms = 5000
args = @{ include_all_visible = $true }
} | ConvertTo-Json -Depth 8 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8
```
查看候选窗口:
```powershell
Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json |
Select-Object -ExpandProperty data |
Select-Object -ExpandProperty windows |
Where-Object { $_.process_name -match "iSphere|IMPlatform|IMPP" -or $_.title -match "iSphere|IMPlatform|IMPP" } |
Format-Table hwnd,pid,process_name,title,class_name,visible,score -AutoSize
```
从输出中选择 iSphere / IMPlatformClient 主窗口的 `hwnd`
示例中之前的候选值是:
```text
0x290E6E
```
如果这次扫描输出变了,请使用新的 `hwnd`
## 4. 正确保存 selected-window.json
把下面命令中的 `0x290E6E` 改成你刚刚确认的真实窗口句柄。
注意:不要加尖括号。
正确:
```powershell
$selectedHwnd = "0x290E6E"
```
错误:
```powershell
$selectedHwnd = "<0x290E6E>"
```
保存所选窗口元数据:
```powershell
$selectedHwnd = "0x290E6E"
$scanObj = Get-Content -Path "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json
$selected = $scanObj.data.windows | Where-Object { $_.hwnd -eq $selectedHwnd } | Select-Object -First 1
if (-not $selected) { throw "selected hwnd not found in scan result: $selectedHwnd" }
$selected | ConvertTo-Json -Depth 8 | Set-Content -Path "$captureRoot\windows\selected-window.json" -Encoding UTF8
```
确认文件已经生成:
```powershell
Test-Path -LiteralPath "$captureRoot\windows\selected-window.json"
```
期望输出:
```text
True
```
## 5. 重新执行 dump_uia
```powershell
@{
protocol = "isphere.helper.v1"
request_id = "n12r-dump-uia-main-retry"
op = "dump_uia"
timeout_ms = 15000
args = @{
hwnd = $selectedHwnd
max_depth = 8
include_text = $true
max_children = 100
}
} | ConvertTo-Json -Depth 12 -Compress |
& $helper --json |
Set-Content -Path "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8
```
检查结果是否成功:
```powershell
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
$dump.ok
$dump.error
```
期望:
```text
True
```
并且 `$dump.error` 应为空或为 `null`
如果仍然是 `WINDOW_NOT_FOUND`,说明窗口句柄已经失效。请回到第 3 步重新扫描并选择新的 hwnd。
## 6. 重新创建脱敏副本
```powershell
Copy-Item -LiteralPath "$captureRoot\uia\dump-uia-main.json" -Destination "$captureRoot\uia\dump-uia-main-redacted.json" -Force
```
然后人工打开:
```text
$captureRoot\uia\dump-uia-main-redacted.json
```
脱敏可见敏感文本,例如:
```text
人员姓名
手机号
邮箱
消息文本
联系人名称
敏感业务标题
类似 token / 密码 / cookie 的字符串
```
尽量保留这些结构字段:
```text
control_type
automation_id
class_name
framework_id
bounds
is_enabled
is_offscreen
children
```
## 7. 修正 source-notes.txt
打开:
```text
$captureRoot\metadata\source-notes.txt
```
把这些字段从 `是/否` 改成真实值:
```text
iSphere manually logged in: 是
client opened before capture: 是
main window visible: 是
```
如果真实情况不是“是”,不要继续打包,先重新完成对应动作。
## 8. 修正 operator-notes.txt
打开:
```text
$captureRoot\notes\operator-notes.txt
```
不要保留 `<...>` 示例内容。请改成真实说明。
示例:
```text
打开的客户端: iSphere / IMPlatformClient
登录方式: 人工正常公司流程
是否使用 MFA/SSO: 是,不包含任何秘密
所选窗口 hwnd: 0x290E6E
选择该窗口的原因: process_name 为 IMPlatformClient标题为国网甘肃省电力公司visible=Truescore=65
已做脱敏: 已检查 dump-uia-main-redacted.json移除可见敏感文本
采集问题: 首次把 hwnd 写成 <0x290E6E> 导致失败,本次已修正为 0x290E6E 后重新采集
forbidden actions performed: no
```
## 9. 复制出去前必须验证
运行必需文件检查:
```powershell
$required = @(
"$captureRoot\metadata\source-notes.txt",
"$captureRoot\helper\version.json",
"$captureRoot\helper\self-check.json",
"$captureRoot\windows\scan-windows.json",
"$captureRoot\windows\selected-window.json",
"$captureRoot\uia\dump-uia-main.json",
"$captureRoot\uia\dump-uia-main-redacted.json",
"$captureRoot\notes\operator-notes.txt"
)
foreach ($path in $required) {
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $path" }
}
"N12R retry package files present: $captureRoot" | Tee-Object -FilePath "$captureRoot\metadata\verification-summary.txt"
```
运行 JSON 解析检查:
```powershell
Get-Content "$captureRoot\helper\version.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\helper\self-check.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\scan-windows.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\windows\selected-window.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
Get-Content "$captureRoot\uia\dump-uia-main-redacted.json" -Encoding UTF8 | ConvertFrom-Json | Out-Null
```
检查 UIA dump 必须是成功:
```powershell
$dump = Get-Content "$captureRoot\uia\dump-uia-main.json" -Encoding UTF8 | ConvertFrom-Json
if (-not $dump.ok) { throw "dump_uia failed: $($dump.error | ConvertTo-Json -Depth 8 -Compress)" }
```
## 10. 重新压缩返回
```powershell
Compress-Archive -LiteralPath $captureRoot -DestinationPath "$captureRoot.zip" -Force
```
把新的 zip 或目录返回给外网协调环境。
返回时请同时说明:
```text
N12R 重采/补采包已准备好。
路径: runs/internal-sandbox-live-capture/2026-07-08-internal-sandbox-a/
人工 iSphere 登录: 是
采集期间主窗口可见: 是
forbidden actions performed: no
本次已修正 hwnd不再包含尖括号selected-window.json 已生成dump_uia ok=true
```
## 11. 外网收到后如何判断
外网协调环境会重新运行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot <返回包目录> `
-ReportPath <包外报告路径>
```
只有 validator 输出:
```text
Decision: ACCEPT
```
才能进入 N13 selector 设计。

View File

@@ -0,0 +1,223 @@
# N12R Return Package Validation Checklist
Use this checklist in the outer-network coordination environment after an internal-network operator returns a live capture package.
N12R packages are expected under:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
This checklist validates package completeness and boundary compliance. It does not execute copied client binaries and does not mark original current-environment N12 as passed.
## 1. Required files
A package is structurally complete only if these files exist:
```text
metadata/source-notes.txt
helper/version.json
helper/self-check.json
windows/scan-windows.json
windows/selected-window.json
uia/dump-uia-main.json
uia/dump-uia-main-redacted.json
notes/operator-notes.txt
```
Recommended metadata files:
```text
metadata/os-version.txt
metadata/process-list.txt
metadata/repo-status.txt
metadata/verification-summary.txt
```
## 2. Read-only validation commands
Set package path:
```powershell
$captureRoot = "runs\internal-sandbox-live-capture\<capture-id>"
```
Preferred one-command validation:
```powershell
$reportRoot = "runs\internal-sandbox-live-capture-reports"
New-Item -ItemType Directory -Force -Path $reportRoot | Out-Null
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate-n12r-return-package.ps1 `
-PackageRoot $captureRoot `
-ReportPath (Join-Path $reportRoot "<capture-id>-validation.md")
```
The report path must stay outside the returned package root so validation does not modify the returned evidence package.
Check required files:
```powershell
$required = @(
"metadata\source-notes.txt",
"helper\version.json",
"helper\self-check.json",
"windows\scan-windows.json",
"windows\selected-window.json",
"uia\dump-uia-main.json",
"uia\dump-uia-main-redacted.json",
"notes\operator-notes.txt"
)
foreach ($rel in $required) {
$path = Join-Path $captureRoot $rel
if (-not (Test-Path -LiteralPath $path)) { throw "missing required file: $rel" }
}
```
Check JSON parseability:
```powershell
$jsonFiles = @(
"helper\version.json",
"helper\self-check.json",
"windows\scan-windows.json",
"windows\selected-window.json",
"uia\dump-uia-main.json",
"uia\dump-uia-main-redacted.json"
)
foreach ($rel in $jsonFiles) {
Get-Content -LiteralPath (Join-Path $captureRoot $rel) -Encoding UTF8 | ConvertFrom-Json | Out-Null
}
```
## 3. Metadata acceptance checks
`metadata/source-notes.txt` must state:
```text
source_environment: internal-network test sandbox
iSphere manually logged in: yes
client opened before capture: yes
main window visible: yes
operations used: version, self_check, scan_windows, dump_uia
forbidden actions performed: no
this package is N12R live evidence, not current-environment N12 pass
```
`notes/operator-notes.txt` must state:
```text
How login was performed: manual normal company process
Forbidden actions performed: no
```
If either note file says automatic login, sending, receiving, injection, hook, memory reading, or bypass occurred, reject the package.
## 4. Helper JSON checks
`helper/version.json` should parse to an object where:
```text
ok == true
data.helper_name == ISphereWinHelper
```
`helper/self-check.json` should parse to an object where:
```text
ok == true
data.uia_available is a boolean
```
## 5. Window scan checks
`windows/scan-windows.json` should include at least one candidate where one or more of these fields indicates iSphere / IMPlatformClient / IMPP:
```text
process_name
title
class_name
score
```
`windows/selected-window.json` should be one selected object from `scan-windows.json` and should include:
```text
hwnd
pid
process_name
title
class_name
visible
bounds
score
```
## 6. UIA dump checks
`uia/dump-uia-main.json` should parse to a helper response where:
```text
ok == true
data.root exists
```
The UIA root tree should include selector-useful fields such as:
```text
name
control_type
automation_id
class_name
framework_id
bounds
children
```
The redacted copy `uia/dump-uia-main-redacted.json` must exist before broad sharing.
## 7. Boundary rejection checks
Reject the package for N12R evidence use if any of these are true:
```text
source notes do not confirm manual login
main window was not visible
scan result has no iSphere / IMPlatformClient candidate
UIA dump is missing or empty
redacted UIA copy is missing
package includes raw passwords, tokens, cookies, private keys, or copied credential material
operator notes indicate automatic login, search, opening conversations, sending, receiving, injection, hook, memory reading, or endpoint-security bypass
package was produced by running copied executables from an offline evidence package
```
## 8. Validation result template
After validation, write a short report under:
```text
runs/internal-sandbox-live-capture/<capture-id>/metadata/outer-validation-summary.txt
```
Template:
```text
capture_id: <capture-id>
validated_at: <local time>
validated_by: outer-network coordination environment
required_files_present: yes/no
json_parse_ok: yes/no
manual_login_confirmed: yes/no
main_window_visible_confirmed: yes/no
candidate_window_found: yes/no
uia_dump_ok: yes/no
redacted_copy_present: yes/no
forbidden_actions_indicated: yes/no
validation_result: accepted/rejected
notes: <short notes>
```
## 9. Next decision after acceptance
If accepted, N12R evidence can be used for a selector-design review gate.
Do not implement selectors or action tools directly from this checklist. Selector design requires a separate written plan and explicit approval.

View File

@@ -426,6 +426,36 @@ True N12 can start only when all of these are true:
If the current environment still cannot log in, only N12-pre offline analysis is allowed. N12 remains blocked and must not be marked passed.
## 8.1 Reality update: internal-sandbox live capture route
As of 2026-07-08, the repository environment is outside the internal network, while iSphere / IMPlatformClient is internal-network only. Therefore the active live-capture route is not current-environment N12. It is:
```text
N12R: Internal-sandbox live capture route
```
Use these documents for N12R:
```text
docs/internal-sandbox-live-capture-plan.md
docs/internal-sandbox-operator-runbook.md
docs/n12r-return-package-validation-checklist.md
```
N12-pre offline packages continue to support static file inventory and offline analysis. N12R packages support live read-only UIA evidence collected from an internal-network test sandbox after manual login.
Returned N12R packages should be placed under:
```text
runs/internal-sandbox-live-capture/<capture-id>/
```
Do not mix N12-pre and N12R semantics:
- `runs/offline-evidence-intake/<evidence-id>/` is copied-file offline evidence only.
- `runs/internal-sandbox-live-capture/<capture-id>/` is live read-only UIA capture from the internal sandbox.
- Neither route authorizes automatic login, sending, receiving, injection, hook, memory reading, or endpoint-security bypass.
## 9. Coordinator request checklist for the user
Use this final user-facing request. It intentionally avoids asking the user to understand JSON.
@@ -461,4 +491,4 @@ notes/copy-notes.txt 简单写明从哪里复制、是否登录后复制
- 这只是 N12-pre 离线证据,不是 N12 通过。
- 不需要生成 manifest/scan/dump JSON那些是高级可选路径。
- 不要做自动登录、发送消息、发送文件、接收文件、注入、hook、读内存或规避安防。
```
```

View File

@@ -0,0 +1,943 @@
# N13 UIA Selector Catalog 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:** Build an offline, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions.
**Architecture:** Add a focused Go package `internal/uiaselector` with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text.
**Tech Stack:** Go 1.23.x, standard library `encoding/json`, existing repository test style, PowerShell verification wrapper.
---
## Scope and boundary
This plan implements only offline selector catalog and matching.
It does not modify these files:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content.
The accepted N12R evidence used by tests is:
```text
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json
```
## File structure
Create these files:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
Responsibilities:
- `catalog.go`: selector data structures and initial N13 selector catalog.
- `catalog_test.go`: catalog completeness and boundary tests.
- `dump.go`: UIA dump response and node loading helpers.
- `dump_test.go`: fixture parse tests.
- `matcher.go`: pure in-memory selector matcher.
- `matcher_test.go`: matching behavior and no-visible-text result tests.
- `testdata/n12r-2026-07-09-uia-redacted.json`: redacted accepted N12R UIA fixture copied from the ignored run artifact.
- `scripts/verify-n13-uia-selectors.ps1`: convenience verification script for Go-enabled environments.
## Task 1: Add N12R redacted UIA fixture
**Files:**
- Create: `internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json`
- [ ] **Step 1: Create package directory and copy the accepted redacted fixture**
Run:
```powershell
New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null
Copy-Item `
-LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json `
-Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json `
-Force
```
Expected: `internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json` exists.
- [ ] **Step 2: Verify fixture parses as JSON**
Run:
```powershell
Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null
```
Expected: command exits 0.
- [ ] **Step 3: Commit fixture**
```powershell
git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json
git commit -m "test: add N13 redacted UIA fixture"
```
## Task 2: Define selector catalog types and initial catalog
**Files:**
- Create: `internal/uiaselector/catalog_test.go`
- Create: `internal/uiaselector/catalog.go`
- [ ] **Step 1: Write failing catalog tests**
Create `internal/uiaselector/catalog_test.go`:
```go
package uiaselector
import (
"strings"
"testing"
)
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
catalog := DefaultCatalog()
wantIDs := []string{
"main_window",
"main_plugin_tab",
"home_page_window",
"tab_page_content",
"left_panel",
"roster_panel",
"roster_view",
"fuzzy_query_panel",
"fuzzy_query_window",
"fuzzy_search_edit",
}
byID := map[string]Selector{}
for _, selector := range catalog {
byID[selector.ID] = selector
}
for _, id := range wantIDs {
selector, ok := byID[id]
if !ok {
t.Fatalf("DefaultCatalog missing selector %q", id)
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
}
if selector.Required.AutomationID == "" {
t.Fatalf("selector %q must require automation_id", id)
}
if selector.Required.ControlType == "" {
t.Fatalf("selector %q must require control_type", id)
}
if selector.StabilityScore < 80 {
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
}
}
}
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
for _, selector := range DefaultCatalog() {
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
}
lower := strings.ToLower(selector.ID + " " + selector.Description)
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
}
}
}
}
```
- [ ] **Step 2: Run catalog tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
```
Expected: FAIL because package `internal/uiaselector` or `DefaultCatalog` does not exist.
If this environment reports `go` not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment.
- [ ] **Step 3: Implement catalog types and selectors**
Create `internal/uiaselector/catalog.go`:
```go
package uiaselector
const AllowedUseReadOnlyLocate = "read_only_locate"
type Selector struct {
ID string `json:"id"`
Description string `json:"description"`
AllowedUse string `json:"allowed_use"`
Required Criteria `json:"required"`
Preferred PreferredCriteria `json:"preferred"`
Fallback []FallbackCriteria `json:"fallback"`
StabilityScore int `json:"stability_score"`
Evidence Evidence `json:"evidence"`
}
type Criteria struct {
AutomationID string `json:"automation_id,omitempty"`
ControlType string `json:"control_type,omitempty"`
FrameworkID string `json:"framework_id,omitempty"`
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type PreferredCriteria struct {
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
PathHint string `json:"path_hint,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type FallbackCriteria struct {
AncestorID string `json:"ancestor_selector,omitempty"`
ControlType string `json:"control_type,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
}
type Evidence struct {
CaptureID string `json:"capture_id"`
UIAPath string `json:"uia_path"`
}
func DefaultCatalog() []Selector {
return []Selector{
{
ID: "main_window",
Description: "iSphere / IMPlatformClient 主窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
StabilityScore: 95,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
},
{
ID: "main_plugin_tab",
Description: "主功能 tab 容器",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
},
{
ID: "home_page_window",
Description: "首页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
},
{
ID: "tab_page_content",
Description: "tab 页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
},
{
ID: "left_panel",
Description: "左侧主面板",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
StabilityScore: 92,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
},
{
ID: "roster_panel",
Description: "组织/联系人容器外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
},
{
ID: "roster_view",
Description: "组织/联系人列表区域",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 86,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
},
{
ID: "fuzzy_query_panel",
Description: "模糊搜索组件外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
},
{
ID: "fuzzy_query_window",
Description: "模糊搜索内部窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
},
{
ID: "fuzzy_search_edit",
Description: "模糊搜索输入框",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
StabilityScore: 93,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
},
}
}
```
- [ ] **Step 4: Run catalog tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestDefaultCatalog -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit catalog**
```powershell
git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go
git commit -m "feat: add N13 UIA selector catalog"
```
## Task 3: Load redacted UIA dump fixture
**Files:**
- Create: `internal/uiaselector/dump_test.go`
- Create: `internal/uiaselector/dump.go`
- [ ] **Step 1: Write failing dump loading tests**
Create `internal/uiaselector/dump_test.go`:
```go
package uiaselector
import "testing"
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
if root.AutomationID != "frmMain" {
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
}
if root.ControlType != "Window" {
t.Fatalf("root control_type = %q, want Window", root.ControlType)
}
if got := CountNodes(root); got != 66 {
t.Fatalf("CountNodes = %d, want 66", got)
}
}
```
- [ ] **Step 2: Run dump tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
```
Expected: FAIL because `LoadDumpFile` and `CountNodes` do not exist.
- [ ] **Step 3: Implement dump loading**
Create `internal/uiaselector/dump.go`:
```go
package uiaselector
import (
"encoding/json"
"fmt"
"os"
)
type DumpResponse struct {
OK bool `json:"ok"`
Data *DumpData `json:"data"`
Error any `json:"error"`
}
type DumpData struct {
Root *Node `json:"root"`
}
type Node struct {
Name string `json:"name"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
IsEnabled *bool `json:"is_enabled"`
IsOffscreen *bool `json:"is_offscreen"`
Bounds any `json:"bounds"`
Children []Node `json:"children"`
}
func LoadDumpFile(path string) (*Node, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var response DumpResponse
if err := json.Unmarshal(content, &response); err != nil {
return nil, err
}
if !response.OK {
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
}
if response.Data == nil || response.Data.Root == nil {
return nil, fmt.Errorf("dump response has no data.root")
}
return response.Data.Root, nil
}
func CountNodes(root *Node) int {
if root == nil {
return 0
}
count := 1
for i := range root.Children {
count += CountNodes(&root.Children[i])
}
return count
}
```
- [ ] **Step 4: Run dump tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestLoadDumpFixture -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit dump loader**
```powershell
git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go
git commit -m "feat: load N13 UIA dump fixture"
```
## Task 4: Implement read-only selector matcher
**Files:**
- Create: `internal/uiaselector/matcher_test.go`
- Create: `internal/uiaselector/matcher.go`
- [ ] **Step 1: Write failing matcher tests**
Create `internal/uiaselector/matcher_test.go`:
```go
package uiaselector
import "testing"
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
tests := []struct {
selectorID string
path string
control string
autoID string
}{
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
}
for _, tt := range tests {
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
if !result.OK {
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
}
if !result.Matched {
t.Fatalf("%s matched=false", tt.selectorID)
}
if result.MatchCount != 1 {
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
}
match := result.Matches[0]
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
t.Fatalf("%s match = %#v", tt.selectorID, match)
}
}
}
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "main_window")
if !result.OK || !result.Matched || len(result.Matches) != 1 {
t.Fatalf("unexpected result: %#v", result)
}
match := result.Matches[0]
if match.Name != "" {
t.Fatalf("match returned visible name text %q", match.Name)
}
if !match.HasName {
t.Fatalf("main_window should report has_name=true")
}
if match.NameLength == 0 {
t.Fatalf("main_window should report non-zero name_length")
}
}
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
if result.OK {
t.Fatalf("OK=true for missing selector: %#v", result)
}
if result.Error.Code != "UNKNOWN_SELECTOR" {
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
}
}
```
- [ ] **Step 2: Run matcher tests and verify they fail**
Run:
```powershell
go test ./internal/uiaselector -run TestMatchSelector -count=1
```
Expected: FAIL because `MatchSelector` and result types do not exist.
- [ ] **Step 3: Implement matcher**
Create `internal/uiaselector/matcher.go`:
```go
package uiaselector
import "strings"
type MatchResult struct {
OK bool `json:"ok"`
SelectorID string `json:"selector_id"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []NodeMatch `json:"matches"`
Warnings []string `json:"warnings"`
Error MatchError `json:"error,omitempty"`
}
type MatchError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type NodeMatch struct {
Path string `json:"path"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
HasName bool `json:"has_name"`
NameLength int `json:"name_length"`
Name string `json:"-"`
}
type nodeRef struct {
path string
node *Node
}
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
selector, ok := findSelector(catalog, selectorID)
if !ok {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
}
refs := flatten(root)
matches := matchCriteria(refs, selector.Required)
if len(matches) == 0 {
for _, fallback := range selector.Fallback {
matches = matchFallback(refs, fallback)
if len(matches) > 0 {
break
}
}
}
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
for _, ref := range matches {
result.Matches = append(result.Matches, summarizeNode(ref))
}
if !result.Matched {
result.Warnings = append(result.Warnings, "selector_not_found")
}
return result
}
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
for _, selector := range catalog {
if selector.ID == selectorID {
return selector, true
}
}
return Selector{}, false
}
func flatten(root *Node) []nodeRef {
refs := []nodeRef{}
var walk func(node *Node, path string)
walk = func(node *Node, path string) {
if node == nil {
return
}
refs = append(refs, nodeRef{path: path, node: node})
for i := range node.Children {
walk(&node.Children[i], path+"/"+itoa(i))
}
}
walk(root, "root")
return refs
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value = value / 10
}
return string(digits)
}
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
continue
}
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
continue
}
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
continue
}
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
continue
}
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
continue
}
matches = append(matches, ref)
}
return matches
}
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
continue
}
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
continue
}
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
continue
}
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
continue
}
matches = append(matches, ref)
}
return matches
}
func hasChildAutomationIDs(node *Node, ids []string) bool {
for _, id := range ids {
found := false
for i := range node.Children {
if node.Children[i].AutomationID == id {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func hasChildControlTypes(node *Node, controlTypes []string) bool {
for _, controlType := range controlTypes {
found := false
for i := range node.Children {
if node.Children[i].ControlType == controlType {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func summarizeNode(ref nodeRef) NodeMatch {
return NodeMatch{
Path: ref.path,
ControlType: ref.node.ControlType,
AutomationID: ref.node.AutomationID,
ClassName: ref.node.ClassName,
FrameworkID: ref.node.FrameworkID,
HasName: ref.node.Name != "",
NameLength: len(ref.node.Name),
}
}
```
- [ ] **Step 4: Run matcher tests and verify they pass**
Run:
```powershell
go test ./internal/uiaselector -run TestMatchSelector -count=1
```
Expected: PASS.
- [ ] **Step 5: Commit matcher**
```powershell
git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go
git commit -m "feat: match N13 UIA selectors offline"
```
## Task 5: Add verification script
**Files:**
- Create: `scripts/verify-n13-uia-selectors.ps1`
- [ ] **Step 1: Create verification script**
Create `scripts/verify-n13-uia-selectors.ps1`:
```powershell
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go was not found. Run this verifier in a Go 1.23.x environment."
}
Write-Host "## go test ./internal/uiaselector"
go test ./internal/uiaselector -count=1
if ($LASTEXITCODE -ne 0) {
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
}
Write-Host "## boundary grep"
$forbidden = @(
"send_after_approval",
"send_file_after_approval",
"receive_file_after_approval",
"open_conversation",
"ValuePattern.SetValue",
"InvokePattern"
)
$files = @(
"internal\uiaselector\catalog.go",
"internal\uiaselector\matcher.go"
)
foreach ($term in $forbidden) {
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
if ($hit) {
throw "forbidden action term found in selector implementation: $term"
}
}
Write-Host "N13 UIA selector verification passed."
```
- [ ] **Step 2: Run script and verify it passes**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
```
Expected in Go-enabled environment:
```text
## go test ./internal/uiaselector
ok isphere-ai-bridge/internal/uiaselector
## boundary grep
N13 UIA selector verification passed.
```
If `go` is unavailable, expected failure is:
```text
go was not found. Run this verifier in a Go 1.23.x environment.
```
- [ ] **Step 3: Commit verification script**
```powershell
git add scripts\verify-n13-uia-selectors.ps1
git commit -m "test: add N13 selector verifier"
```
## Task 6: Final verification and checkpoint
**Files:**
- No new files.
- Verify: `internal/uiaselector/*`, `scripts/verify-n13-uia-selectors.ps1`
- [ ] **Step 1: Run package tests**
Run:
```powershell
go test ./internal/uiaselector -count=1
```
Expected:
```text
ok isphere-ai-bridge/internal/uiaselector
```
- [ ] **Step 2: Run repository tests if Go is available**
Run:
```powershell
go test ./...
```
Expected: PASS for all packages.
If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun.
- [ ] **Step 3: Run N13 verifier**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1
```
Expected: PASS in Go-enabled environment.
- [ ] **Step 4: Check no MCP/helper action surface changed**
Run:
```powershell
git diff --name-only origin/main..HEAD
```
Expected changed implementation files are limited to:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
If `native/ISphereWinHelper/Program.cs`, `internal/tools/winhelper.go`, or `cmd/isphere-mcp/main.go` appears, stop and review because N13 is not supposed to add live tools.
- [ ] **Step 5: Commit final checkpoint if needed**
If any verification-only documentation changes were made, run:
```powershell
git add <changed verification docs>
git commit -m "docs: record N13 selector verification"
```
If no files changed, do not create an empty commit.
## Self-review checklist
- Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification.
- Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior.
- TDD: every implementation task starts with failing tests before code.
- Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits.
- Environment caveat: Go 1.23.x is required for implementation verification.

View File

@@ -0,0 +1,597 @@
# N13 UIA Selector Catalog Design
日期2026-07-09
## 1. 目标
N13 的目标是基于已 ACCEPT 的 N12R live UIA 证据,设计一套只读 selector catalog 和定位验证流程。
本阶段只做设计,不新增 helper op不新增 MCP tool不点击、不输入、不打开会话、不发送消息、不传输文件。
N13 完成后,下一步才可以写独立实现计划,考虑是否新增只读定位验证能力。
## 2. 输入证据
N12R 返回包已通过外网 validator
```text
runs/internal-sandbox-live-capture-reports/2026-07-09-retry-2/2026-07-08-internal-sandbox-a-validation.md
Decision: ACCEPT
PASS: 36
WARN: 1
FAIL: 0
```
使用的 UIA dump
```text
runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main.json
```
关键事实:
```text
root control_type: Window
root automation_id: frmMain
root class_name: WindowsForms10.Window.8.app.0.d3a00f_r29_ad1
framework_id: WinForm
UIA node count: 66
max_depth: 7
```
本设计只引用结构性字段,不依赖敏感可见文本。
## 3. 非目标
N13 不做以下事项:
```text
自动登录
搜索联系人
打开会话
点击控件
输入文本
发送消息
发送文件
接收文件
读取或导出聊天内容
注入进程
hook
读取目标进程内存
绕过终端安全/安防
```
N13 也不把 `search_contacts``open_conversation``read_latest_messages` 作为当前实现目标。这些只能在 selector catalog 稳定、只读定位验证通过、并形成新的审批设计后再讨论。
## 4. 推荐方案
采用“selector catalog + 只读定位验证”方案。
核心思想:
1. 用 N12R UIA dump 建立一份 selector catalog。
2. 每个 selector 都只描述如何识别一个 UIA 节点。
3. selector 的初始用途只允许 read-only locate。
4. 后续如果实现 `locate_uia`,也只能返回匹配节点摘要,不执行任何 UI 动作。
5. 任何真实动作类能力必须另写设计、计划和审批边界。
## 5. Selector 数据模型
建议 selector 条目使用以下字段:
```json
{
"id": "main_window",
"description": "主窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "frmMain",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"path_hint": "root"
},
"fallback": [
{
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
}
],
"stability_score": 95,
"evidence": {
"capture_id": "2026-07-08-internal-sandbox-a",
"uia_path": "root"
}
}
```
字段含义:
- `id`:稳定、短小、英文蛇形命名。
- `description`:中文说明。
- `allowed_use`N13 固定为 `read_only_locate`
- `required`:必须同时满足的字段。
- `preferred`:加分项,用于减少误匹配。
- `fallback`:主 selector 失效时的只读匹配策略。
- `stability_score`0 到 100越高越稳定。
- `evidence`:来源证据路径和 UIA 路径。
## 6. 初始 selector catalog
### 6.1 主窗口:`main_window`
```json
{
"id": "main_window",
"description": "iSphere / IMPlatformClient 主窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "frmMain",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"path_hint": "root"
},
"fallback": [
{
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0",
"child_automation_id_contains": ["panelLeft", "ucPluginTabControl1"]
}
],
"stability_score": 95,
"evidence": {
"uia_path": "root"
}
}
```
理由:`automation_id=frmMain` 出现在根节点root 是 N12R 已确认主窗口,稳定性最高。
### 6.2 主 tab 容器:`main_plugin_tab`
```json
{
"id": "main_plugin_tab",
"description": "主功能 tab 容器",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucPluginTabControl1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3",
"ancestor_selector": "main_window"
},
"fallback": [
{
"ancestor_selector": "main_window",
"control_type": "Pane",
"child_automation_id_contains": ["FrmHomePage", "ucTabPage"]
}
],
"stability_score": 90,
"evidence": {
"uia_path": "root/3"
}
}
```
理由该节点直接挂在主窗口下automation_id 明确,子结构包含首页和 tab 内容区。
### 6.3 首页窗口:`home_page_window`
```json
{
"id": "home_page_window",
"description": "首页内容窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmHomePage",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3/0/0",
"ancestor_selector": "main_plugin_tab"
},
"fallback": [
{
"ancestor_selector": "main_plugin_tab",
"control_type": "Window",
"class_name_prefix": "WindowsForms10.Window.8.app.0"
}
],
"stability_score": 82,
"evidence": {
"uia_path": "root/3/0/0"
}
}
```
理由:该节点可用于确认当前主 UI 内容区存在,但不用于动作。
### 6.4 tab 内容窗口:`tab_page_content`
```json
{
"id": "tab_page_content",
"description": "tab 页内容窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmTabPageContent",
"control_type": "Window",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/3/1/0",
"ancestor_selector": "main_plugin_tab"
},
"fallback": [
{
"ancestor_selector": "main_plugin_tab",
"control_type": "Window",
"child_control_type_contains": ["TitleBar"]
}
],
"stability_score": 82,
"evidence": {
"uia_path": "root/3/1/0"
}
}
```
理由:可作为后续只读结构检查的内容区域锚点。
### 6.5 左侧面板:`left_panel`
```json
{
"id": "left_panel",
"description": "左侧主面板",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "panelLeft",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4",
"ancestor_selector": "main_window"
},
"fallback": [
{
"ancestor_selector": "main_window",
"control_type": "Pane",
"child_automation_id_contains": ["ucPnlRoster1", "ucFuzzyQuery1"]
}
],
"stability_score": 92,
"evidence": {
"uia_path": "root/4"
}
}
```
理由:该节点是联系人/搜索相关区域的稳定父节点。
### 6.6 组织/联系人容器:`roster_panel`
```json
{
"id": "roster_panel",
"description": "组织/联系人容器外层",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucPnlRoster1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/0/0",
"ancestor_selector": "left_panel"
},
"fallback": [
{
"ancestor_selector": "left_panel",
"control_type": "Pane",
"child_automation_id_contains": ["ucRoster1"]
}
],
"stability_score": 88,
"evidence": {
"uia_path": "root/4/0/0"
}
}
```
理由:该节点是 roster 结构外层,可以只读验证联系人区域是否存在。
### 6.7 组织/联系人列表:`roster_view`
```json
{
"id": "roster_view",
"description": "组织/联系人列表区域",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucRoster1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/0/0/0",
"ancestor_selector": "roster_panel"
},
"fallback": [
{
"ancestor_selector": "roster_panel",
"control_type": "Pane",
"class_name_prefix": "WindowsForms10.Window.8.app.0"
}
],
"stability_score": 86,
"evidence": {
"uia_path": "root/4/0/0/0"
}
}
```
理由:该节点本次 child_count 为 0不能证明列表项结构只能作为区域存在性 selector。
### 6.8 搜索组件外层:`fuzzy_query_panel`
```json
{
"id": "fuzzy_query_panel",
"description": "模糊搜索组件外层",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "ucFuzzyQuery1",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/1/0",
"ancestor_selector": "left_panel"
},
"fallback": [
{
"ancestor_selector": "left_panel",
"control_type": "Pane",
"child_automation_id_contains": ["FrmFuzzyQuery"]
}
],
"stability_score": 90,
"evidence": {
"uia_path": "root/4/1/0"
}
}
```
理由:该组件是搜索输入框的稳定父级。
### 6.9 搜索窗口:`fuzzy_query_window`
```json
{
"id": "fuzzy_query_window",
"description": "模糊搜索内部窗口",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "FrmFuzzyQuery",
"control_type": "Pane",
"framework_id": "WinForm"
},
"preferred": {
"path_hint": "root/4/1/0/0",
"ancestor_selector": "fuzzy_query_panel"
},
"fallback": [
{
"ancestor_selector": "fuzzy_query_panel",
"control_type": "Pane",
"child_automation_id_contains": ["skinAlphaTxt"]
}
],
"stability_score": 88,
"evidence": {
"uia_path": "root/4/1/0/0"
}
}
```
理由:该节点包含搜索输入框,是后续只读定位验证中的关键父节点。
### 6.10 搜索输入框:`fuzzy_search_edit`
```json
{
"id": "fuzzy_search_edit",
"description": "模糊搜索输入框",
"allowed_use": "read_only_locate",
"required": {
"automation_id": "skinAlphaTxt",
"control_type": "Edit",
"framework_id": "WinForm"
},
"preferred": {
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
"path_hint": "root/4/1/0/0/2",
"ancestor_selector": "fuzzy_query_window"
},
"fallback": [
{
"ancestor_selector": "fuzzy_query_window",
"control_type": "Edit",
"class_name_prefix": "WindowsForms10.EDIT.app.0"
}
],
"stability_score": 93,
"evidence": {
"uia_path": "root/4/1/0/0/2"
}
}
```
理由:该节点 automation_id、control_type 和 class_name 都明确。N13 只允许定位它,不允许输入搜索词。
## 7. 只读定位验证规则
后续如果实现定位验证,只能做以下事情:
1. 接收 selector id。
2. 读取当前窗口 UIA 树。
3. 按 selector required 字段过滤节点。
4. 用 preferred 字段排序或加分。
5. 如果主 selector 无匹配,再尝试 fallback。
6. 返回节点摘要:`selector_id``matched``match_count``path``control_type``automation_id``class_name``framework_id``is_enabled``is_offscreen``bounds`
禁止返回完整 visible text。可见文本只能返回以下派生信息
```text
has_name: true/false
name_length: number
```
禁止执行:
```text
InvokePattern
ValuePattern.SetValue
SelectionItemPattern.Select
ExpandCollapsePattern.Expand
点击
键盘输入
剪贴板操作
窗口激活
焦点切换
```
## 8. 稳定性评分规则
建议评分:
```text
95-100: root 或唯一主窗口级 anchor
90-94: automation_id 明确、control_type 明确、父子路径稳定
80-89: automation_id 明确,但可能依赖 tab 或运行状态
70-79: 缺 automation_id但 class/control/path 组合可用
0-69: 不进入首批 catalog
```
本次首批 selector 全部要求 automation_id 明确。
## 9. 错误处理设计
只读定位验证应返回结构化结果,不抛出模糊错误。
建议结果:
```json
{
"ok": true,
"selector_id": "fuzzy_search_edit",
"matched": true,
"match_count": 1,
"matches": [
{
"path": "root/4/1/0/0/2",
"control_type": "Edit",
"automation_id": "skinAlphaTxt",
"class_name": "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1",
"framework_id": "WinForm",
"has_name": true,
"name_length": 0
}
],
"warnings": []
}
```
未匹配时:
```json
{
"ok": true,
"selector_id": "fuzzy_search_edit",
"matched": false,
"match_count": 0,
"matches": [],
"warnings": ["selector_not_found"]
}
```
selector 定义非法时:
```json
{
"ok": false,
"selector_id": "fuzzy_search_edit",
"error": {
"code": "INVALID_SELECTOR",
"message": "selector required fields are incomplete"
}
}
```
## 10. 测试策略
N13 设计阶段不写实现代码,但后续实现计划必须包含以下测试:
1. 从 N12R dump fixture 读取 UIA 树。
2. 验证 `main_window` 匹配唯一 root。
3. 验证 `left_panel` 匹配 `root/4`
4. 验证 `fuzzy_search_edit` 匹配 `root/4/1/0/0/2`
5. 验证 selector 不返回完整 `name` 文本,只返回 `has_name``name_length`
6. 验证缺失 selector 返回 `matched=false`,不报崩溃。
7. 验证动作模式字段或动作请求被拒绝。
8. 验证 catalog 中不包含 `send``upload``download``conversation``login` 等动作词作为工具名。
## 11. 文件规划
如果后续进入实现计划,建议新增或修改:
```text
internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1
```
暂不修改:
```text
native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go
```
除非新的实现计划明确批准新增只读定位工具,否则 Go MCP 仍只暴露现有四个只读工具。
## 12. N13 验收条件
N13 设计可接受条件:
1. selector catalog 只依赖结构性 UIA 字段。
2. 不依赖敏感可见文本。
3. 首批 selector 都能追溯到 N12R ACCEPT dump 的路径。
4. allowed_use 全部是 `read_only_locate`
5. 文档明确禁止点击、输入、打开会话、发送和文件操作。
6. 后续实现计划必须 TDD并先使用 fixture 测试 selector matcher。
## 13. 推荐下一步
下一步不是直接写代码,而是写一份 N13 implementation plan。
该计划应先实现纯离线 selector matcher对 N12R UIA dump fixture 做只读匹配测试。
只有离线 selector matcher 通过后,才讨论是否新增 live `locate_uia` helper/MCP 只读工具。

View File

@@ -0,0 +1,148 @@
package uiaselector
const AllowedUseReadOnlyLocate = "read_only_locate"
type Selector struct {
ID string `json:"id"`
Description string `json:"description"`
AllowedUse string `json:"allowed_use"`
Required Criteria `json:"required"`
Preferred PreferredCriteria `json:"preferred"`
Fallback []FallbackCriteria `json:"fallback"`
StabilityScore int `json:"stability_score"`
Evidence Evidence `json:"evidence"`
}
type Criteria struct {
AutomationID string `json:"automation_id,omitempty"`
ControlType string `json:"control_type,omitempty"`
FrameworkID string `json:"framework_id,omitempty"`
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type PreferredCriteria struct {
ClassName string `json:"class_name,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
PathHint string `json:"path_hint,omitempty"`
AncestorID string `json:"ancestor_selector,omitempty"`
}
type FallbackCriteria struct {
AncestorID string `json:"ancestor_selector,omitempty"`
ControlType string `json:"control_type,omitempty"`
ClassPrefix string `json:"class_name_prefix,omitempty"`
ChildAutomationIDContains []string `json:"child_automation_id_contains,omitempty"`
ChildControlTypeContains []string `json:"child_control_type_contains,omitempty"`
}
type Evidence struct {
CaptureID string `json:"capture_id"`
UIAPath string `json:"uia_path"`
}
func DefaultCatalog() []Selector {
return []Selector{
{
ID: "main_window",
Description: "iSphere / IMPlatformClient 主窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
StabilityScore: 95,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
},
{
ID: "main_plugin_tab",
Description: "主功能 tab 容器",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
},
{
ID: "home_page_window",
Description: "首页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
},
{
ID: "tab_page_content",
Description: "tab 页内容窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
StabilityScore: 82,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
},
{
ID: "left_panel",
Description: "左侧主面板",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
StabilityScore: 92,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
},
{
ID: "roster_panel",
Description: "组织/联系人容器外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
},
{
ID: "roster_view",
Description: "组织/联系人列表区域",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
StabilityScore: 86,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
},
{
ID: "fuzzy_query_panel",
Description: "模糊搜索组件外层",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
StabilityScore: 90,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
},
{
ID: "fuzzy_query_window",
Description: "模糊搜索内部窗口",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
StabilityScore: 88,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
},
{
ID: "fuzzy_search_edit",
Description: "模糊搜索输入框",
AllowedUse: AllowedUseReadOnlyLocate,
Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
StabilityScore: 93,
Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
},
}
}

View File

@@ -0,0 +1,60 @@
package uiaselector
import (
"strings"
"testing"
)
func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
catalog := DefaultCatalog()
wantIDs := []string{
"main_window",
"main_plugin_tab",
"home_page_window",
"tab_page_content",
"left_panel",
"roster_panel",
"roster_view",
"fuzzy_query_panel",
"fuzzy_query_window",
"fuzzy_search_edit",
}
byID := map[string]Selector{}
for _, selector := range catalog {
byID[selector.ID] = selector
}
for _, id := range wantIDs {
selector, ok := byID[id]
if !ok {
t.Fatalf("DefaultCatalog missing selector %q", id)
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
}
if selector.Required.AutomationID == "" {
t.Fatalf("selector %q must require automation_id", id)
}
if selector.Required.ControlType == "" {
t.Fatalf("selector %q must require control_type", id)
}
if selector.StabilityScore < 80 {
t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
}
}
}
func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
for _, selector := range DefaultCatalog() {
if selector.AllowedUse != AllowedUseReadOnlyLocate {
t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
}
lower := strings.ToLower(selector.ID + " " + selector.Description)
for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
}
}
}
}

View File

@@ -0,0 +1,109 @@
package uiaselector
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
)
type DumpResponse struct {
OK bool `json:"ok"`
Data *DumpData `json:"data"`
Error any `json:"error"`
}
type DumpData struct {
Root *Node `json:"root"`
}
type Node struct {
Name string `json:"name"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
IsEnabled *bool `json:"is_enabled"`
IsOffscreen *bool `json:"is_offscreen"`
Bounds any `json:"bounds"`
Children []Node `json:"children"`
}
func LoadDumpFile(path string) (*Node, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content = normalizeDumpJSON(content)
var response DumpResponse
if err := json.Unmarshal(content, &response); err != nil {
return nil, err
}
if !response.OK {
return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
}
if response.Data == nil || response.Data.Root == nil {
return nil, fmt.Errorf("dump response has no data.root")
}
return response.Data.Root, nil
}
func CountNodes(root *Node) int {
if root == nil {
return 0
}
count := 1
for i := range root.Children {
count += CountNodes(&root.Children[i])
}
return count
}
func normalizeDumpJSON(content []byte) []byte {
content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF})
source := string(content)
var out strings.Builder
out.Grow(len(source))
inString := false
escaped := false
for i := 0; i < len(source); {
ch := source[i]
if inString {
out.WriteByte(ch)
if escaped {
escaped = false
} else if ch == '\\' {
escaped = true
} else if ch == '"' {
inString = false
}
i++
continue
}
if ch == '"' {
inString = true
out.WriteByte(ch)
i++
continue
}
switch {
case strings.HasPrefix(source[i:], "-Infinity"):
out.WriteString("null")
i += len("-Infinity")
case strings.HasPrefix(source[i:], "Infinity"):
out.WriteString("null")
i += len("Infinity")
case strings.HasPrefix(source[i:], "NaN"):
out.WriteString("null")
i += len("NaN")
default:
out.WriteByte(ch)
i++
}
}
return []byte(out.String())
}

View File

@@ -0,0 +1,19 @@
package uiaselector
import "testing"
func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
if root.AutomationID != "frmMain" {
t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
}
if root.ControlType != "Window" {
t.Fatalf("root control_type = %q, want Window", root.ControlType)
}
if got := CountNodes(root); got != 66 {
t.Fatalf("CountNodes = %d, want 66", got)
}
}

View File

@@ -0,0 +1,186 @@
package uiaselector
import "strings"
type MatchResult struct {
OK bool `json:"ok"`
SelectorID string `json:"selector_id"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []NodeMatch `json:"matches"`
Warnings []string `json:"warnings"`
Error MatchError `json:"error,omitempty"`
}
type MatchError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type NodeMatch struct {
Path string `json:"path"`
ControlType string `json:"control_type"`
AutomationID string `json:"automation_id"`
ClassName string `json:"class_name"`
FrameworkID string `json:"framework_id"`
HasName bool `json:"has_name"`
NameLength int `json:"name_length"`
Name string `json:"-"`
}
type nodeRef struct {
path string
node *Node
}
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
selector, ok := findSelector(catalog, selectorID)
if !ok {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
}
if selector.AllowedUse != AllowedUseReadOnlyLocate {
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
}
refs := flatten(root)
matches := matchCriteria(refs, selector.Required)
if len(matches) == 0 {
for _, fallback := range selector.Fallback {
matches = matchFallback(refs, fallback)
if len(matches) > 0 {
break
}
}
}
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
for _, ref := range matches {
result.Matches = append(result.Matches, summarizeNode(ref))
}
if !result.Matched {
result.Warnings = append(result.Warnings, "selector_not_found")
}
return result
}
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
for _, selector := range catalog {
if selector.ID == selectorID {
return selector, true
}
}
return Selector{}, false
}
func flatten(root *Node) []nodeRef {
refs := []nodeRef{}
var walk func(node *Node, path string)
walk = func(node *Node, path string) {
if node == nil {
return
}
refs = append(refs, nodeRef{path: path, node: node})
for i := range node.Children {
walk(&node.Children[i], path+"/"+itoa(i))
}
}
walk(root, "root")
return refs
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value = value / 10
}
return string(digits)
}
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
continue
}
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
continue
}
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
continue
}
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
continue
}
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
continue
}
matches = append(matches, ref)
}
return matches
}
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
matches := []nodeRef{}
for _, ref := range refs {
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
continue
}
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
continue
}
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
continue
}
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
continue
}
matches = append(matches, ref)
}
return matches
}
func hasChildAutomationIDs(node *Node, ids []string) bool {
for _, id := range ids {
found := false
for i := range node.Children {
if node.Children[i].AutomationID == id {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func hasChildControlTypes(node *Node, controlTypes []string) bool {
for _, controlType := range controlTypes {
found := false
for i := range node.Children {
if node.Children[i].ControlType == controlType {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func summarizeNode(ref nodeRef) NodeMatch {
return NodeMatch{
Path: ref.path,
ControlType: ref.node.ControlType,
AutomationID: ref.node.AutomationID,
ClassName: ref.node.ClassName,
FrameworkID: ref.node.FrameworkID,
HasName: ref.node.Name != "",
NameLength: len(ref.node.Name),
}
}

View File

@@ -0,0 +1,73 @@
package uiaselector
import "testing"
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
tests := []struct {
selectorID string
path string
control string
autoID string
}{
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
}
for _, tt := range tests {
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
if !result.OK {
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
}
if !result.Matched {
t.Fatalf("%s matched=false", tt.selectorID)
}
if result.MatchCount != 1 {
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
}
match := result.Matches[0]
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
t.Fatalf("%s match = %#v", tt.selectorID, match)
}
}
}
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "main_window")
if !result.OK || !result.Matched || len(result.Matches) != 1 {
t.Fatalf("unexpected result: %#v", result)
}
match := result.Matches[0]
if match.Name != "" {
t.Fatalf("match returned visible name text %q", match.Name)
}
if !match.HasName {
t.Fatalf("main_window should report has_name=true")
}
if match.NameLength == 0 {
t.Fatalf("main_window should report non-zero name_length")
}
}
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
if err != nil {
t.Fatalf("LoadDumpFile: %v", err)
}
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
if result.OK {
t.Fatalf("OK=true for missing selector: %#v", result)
}
if result.Error.Code != "UNKNOWN_SELECTOR" {
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,189 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
$validator = Join-Path $repo "scripts\validate-n12r-return-package.ps1"
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("n12r-validator-tests-" + [guid]::NewGuid().ToString("N"))
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) {
throw $Message
}
}
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Message) {
if ($Haystack -notlike "*$Needle*") {
throw "$Message`nExpected to contain: $Needle`nActual: $Haystack"
}
}
function Write-Utf8Text([string]$Path, [string]$Content) {
$parent = Split-Path -Parent $Path
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8
}
function Write-Json([string]$Path, [object]$Value) {
Write-Utf8Text -Path $Path -Content ($Value | ConvertTo-Json -Depth 32)
}
function New-GoodPackage([string]$Path) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
foreach ($dir in @("metadata", "helper", "windows", "uia", "notes")) {
New-Item -ItemType Directory -Force -Path (Join-Path $Path $dir) | Out-Null
}
Write-Utf8Text (Join-Path $Path "metadata\source-notes.txt") @"
Capture source: internal-network test sandbox.
Login method: human operator completed normal interactive sign-in.
Boundary: read-only UI Automation capture only.
"@
Write-Utf8Text (Join-Path $Path "metadata\os-version.txt") "Microsoft Windows test sandbox"
Write-Utf8Text (Join-Path $Path "metadata\process-list.txt") "IMPlatformClient 4242"
Write-Utf8Text (Join-Path $Path "metadata\repo-status.txt") "## main...origin/main"
Write-Utf8Text (Join-Path $Path "metadata\verification-summary.txt") "N12R package files present."
Write-Json (Join-Path $Path "helper\version.json") @{
ok = $true
data = @{
helper_name = "ISphereWinHelper"
helper_version = "0.1.0"
}
}
Write-Json (Join-Path $Path "helper\self-check.json") @{
ok = $true
data = @{
desktop_available = $true
uia_available = $true
}
}
Write-Json (Join-Path $Path "windows\scan-windows.json") @{
ok = $true
data = @{
windows = @(
@{
hwnd = "0x123456"
title = "iSphere - Test Sandbox"
process_name = "IMPlatformClient"
process_id = 4242
class_name = "WindowsForms10.Window.8.app.0"
}
)
}
}
Write-Json (Join-Path $Path "windows\selected-window.json") @{
hwnd = "0x123456"
title = "iSphere - Test Sandbox"
process_name = "IMPlatformClient"
process_id = 4242
}
$uiaTree = @{
ok = $true
data = @{
root = @{
name = "iSphere - Test Sandbox"
control_type = "Window"
automation_id = "MainWindow"
children = @(
@{
name = "Conversation list"
control_type = "List"
children = @()
}
)
}
}
}
Write-Json (Join-Path $Path "uia\dump-uia-main.json") $uiaTree
Write-Json (Join-Path $Path "uia\dump-uia-main-redacted.json") $uiaTree
Write-Utf8Text (Join-Path $Path "notes\operator-notes.txt") "Read-only capture. No message/file action was performed."
}
function Invoke-Validator([string]$PackageRoot, [string]$ReportPath) {
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $validator -PackageRoot $PackageRoot -ReportPath $ReportPath 2>&1
[pscustomobject]@{
ExitCode = $LASTEXITCODE
Output = ($output | Out-String)
}
}
function Run-Test([string]$Name, [scriptblock]$Body) {
Write-Host "## $Name"
& $Body
Write-Host "PASS $Name"
}
try {
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
Run-Test "accepts a complete read-only N12R package" {
$pkg = Join-Path $tempRoot "good"
$report = Join-Path $tempRoot "good-report.md"
New-GoodPackage $pkg
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -eq 0) "Expected validator success, got exit $($result.ExitCode): $($result.Output)"
Assert-Contains $result.Output "Decision: ACCEPT" "Expected accept decision in output."
Assert-True (Test-Path -LiteralPath $report) "Expected markdown report to be written outside package root."
Assert-Contains (Get-Content -LiteralPath $report -Raw -Encoding UTF8) "Decision: ACCEPT" "Expected accept decision in report."
}
Run-Test "rejects a package missing the redacted UIA dump" {
$pkg = Join-Path $tempRoot "missing-redacted"
$report = Join-Path $tempRoot "missing-redacted-report.md"
New-GoodPackage $pkg
Remove-Item -LiteralPath (Join-Path $pkg "uia\dump-uia-main-redacted.json") -Force
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -ne 0) "Expected validator failure for missing redacted UIA dump."
Assert-Contains $result.Output "uia/dump-uia-main-redacted.json" "Expected missing required path in output."
Assert-Contains $result.Output "Decision: REJECT" "Expected reject decision in output."
}
Run-Test "rejects malformed required JSON" {
$pkg = Join-Path $tempRoot "bad-json"
$report = Join-Path $tempRoot "bad-json-report.md"
New-GoodPackage $pkg
Write-Utf8Text (Join-Path $pkg "helper\version.json") "{not-json"
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -ne 0) "Expected validator failure for malformed JSON."
Assert-Contains $result.Output "helper/version.json" "Expected malformed JSON path in output."
Assert-Contains $result.Output "JSON parse failed" "Expected JSON parse failure message."
}
Run-Test "rejects obvious forbidden action evidence" {
$pkg = Join-Path $tempRoot "forbidden-action"
$report = Join-Path $tempRoot "forbidden-action-report.md"
New-GoodPackage $pkg
Write-Utf8Text (Join-Path $pkg "notes\operator-notes.txt") "operator accidentally ran send_message during capture"
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -ne 0) "Expected validator failure for forbidden action evidence."
Assert-Contains $result.Output "send_message" "Expected forbidden action token in output."
Assert-Contains $result.Output "Decision: REJECT" "Expected reject decision in output."
}
Run-Test "accepts harmless process names that contain hook as a substring" {
$pkg = Join-Path $tempRoot "harmless-hook-substring"
$report = Join-Path $tempRoot "harmless-hook-substring-report.md"
New-GoodPackage $pkg
Write-Utf8Text (Join-Path $pkg "metadata\process-list.txt") "zfhookupdat 3240"
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -eq 0) "Expected validator success for harmless process-name substring, got exit $($result.ExitCode): $($result.Output)"
Assert-Contains $result.Output "Decision: ACCEPT" "Expected accept decision in output."
}
Run-Test "rejects report paths inside the evidence package" {
$pkg = Join-Path $tempRoot "report-inside-package"
New-GoodPackage $pkg
$report = Join-Path $pkg "validation-report.md"
$result = Invoke-Validator -PackageRoot $pkg -ReportPath $report
Assert-True ($result.ExitCode -ne 0) "Expected validator failure when report path is inside package root."
Assert-Contains $result.Output "ReportPath must be outside PackageRoot" "Expected package immutability error."
Assert-True (-not (Test-Path -LiteralPath $report)) "Validator must not write a report inside the package root."
}
}
finally {
if (Test-Path -LiteralPath $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force
}
}

View File

@@ -0,0 +1,540 @@
param(
[Parameter(Mandatory = $true)]
[string]$PackageRoot,
[string]$ReportPath
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$results = New-Object System.Collections.Generic.List[object]
$parsedJson = @{}
$rawFiles = @{}
$requiredFiles = @(
"metadata/source-notes.txt",
"helper/version.json",
"helper/self-check.json",
"windows/scan-windows.json",
"windows/selected-window.json",
"uia/dump-uia-main.json",
"uia/dump-uia-main-redacted.json",
"notes/operator-notes.txt"
)
$optionalFiles = @(
"metadata/os-version.txt",
"metadata/process-list.txt",
"metadata/repo-status.txt",
"metadata/verification-summary.txt"
)
$jsonFiles = @(
"helper/version.json",
"helper/self-check.json",
"windows/scan-windows.json",
"windows/selected-window.json",
"uia/dump-uia-main.json",
"uia/dump-uia-main-redacted.json"
)
$forbiddenTokens = @(
"send_message",
"search_contact",
"upload_file",
"download_file",
"receive_file",
"open_conversation",
"auto_login",
"autologin",
"file_transfer",
"read_memory",
"memory_dump",
"inject",
"hook"
)
function Add-Result([string]$Status, [string]$Category, [string]$Message) {
$script:results.Add([pscustomobject]@{
Status = $Status
Category = $Category
Message = $Message
}) | Out-Null
}
function Add-Pass([string]$Category, [string]$Message) {
Add-Result "PASS" $Category $Message
}
function Add-Warn([string]$Category, [string]$Message) {
Add-Result "WARN" $Category $Message
}
function Add-Fail([string]$Category, [string]$Message) {
Add-Result "FAIL" $Category $Message
}
function Resolve-AbsolutePath([string]$Path) {
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path))
}
function Ensure-TrailingSeparator([string]$Path) {
$trimmed = $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
return $trimmed + [System.IO.Path]::DirectorySeparatorChar
}
function Join-PackagePath([string]$Root, [string]$RelativePath) {
return Join-Path $Root ($RelativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
}
function Read-TextFile([string]$Root, [string]$RelativePath) {
$path = Join-PackagePath $Root $RelativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
return $null
}
try {
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8
$script:rawFiles[$RelativePath] = $text
return $text
}
catch {
Add-Fail "read" "$RelativePath could not be read as UTF-8 text: $($_.Exception.Message)"
return $null
}
}
function Read-JsonFile([string]$Root, [string]$RelativePath) {
$text = Read-TextFile $Root $RelativePath
if ($null -eq $text) {
return $null
}
try {
$json = $text | ConvertFrom-Json -ErrorAction Stop
$script:parsedJson[$RelativePath] = $json
Add-Pass "json" "$RelativePath parsed as JSON"
return $json
}
catch {
Add-Fail "json" "$RelativePath JSON parse failed: $($_.Exception.Message)"
return $null
}
}
function Get-PropertyValue([object]$Object, [string[]]$Names) {
if ($null -eq $Object) {
return $null
}
foreach ($name in $Names) {
$prop = $Object.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
if ($null -ne $prop) {
return $prop.Value
}
}
return $null
}
function Has-Property([object]$Object, [string]$Name) {
if ($null -eq $Object) {
return $false
}
return $null -ne ($Object.PSObject.Properties | Where-Object { $_.Name -ieq $Name } | Select-Object -First 1)
}
function Test-OkField([object]$Object) {
if ($null -eq $Object) {
return $false
}
if (-not (Has-Property $Object "ok")) {
return $false
}
return [bool](Get-PropertyValue $Object @("ok"))
}
function Convert-ToArray([object]$Value) {
if ($null -eq $Value) {
return @()
}
if ($Value -is [System.Array]) {
return @($Value)
}
return @($Value)
}
function Get-NestedValue([object]$Object, [string[]]$Path) {
$current = $Object
foreach ($segment in $Path) {
if ($null -eq $current) {
return $null
}
$current = Get-PropertyValue $current @($segment)
}
return $current
}
function Get-UiRoot([object]$Json) {
foreach ($path in @(
@("data", "root"),
@("data", "tree"),
@("root"),
@("tree")
)) {
$candidate = Get-NestedValue $Json $path
if ($null -ne $candidate) {
return $candidate
}
}
return $Json
}
function Count-UiNodes([object]$Node) {
if ($null -eq $Node) {
return 0
}
if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string]) -and -not ($Node -is [pscustomobject])) {
$sum = 0
foreach ($item in $Node) {
$sum += Count-UiNodes $item
}
return $sum
}
$count = 1
$children = Get-PropertyValue $Node @("children", "Children")
foreach ($child in (Convert-ToArray $children)) {
$count += Count-UiNodes $child
}
return $count
}
function Format-Value([object]$Value) {
if ($null -eq $Value) {
return ""
}
return [string]$Value
}
function Test-ForbiddenEvidence([string]$RelativePath, [string]$Text) {
if ($null -eq $Text) {
return
}
foreach ($token in $script:forbiddenTokens) {
if ($Text -match [regex]::Escape($token)) {
Add-Fail "boundary" "$RelativePath contains forbidden action token: $token"
}
}
$opPattern = '"(op|tool|action|operation)"\s*:\s*"(send|search|upload|download|receive|auto_login|autologin|open_conversation|file_transfer|read_memory|inject|hook)[^"]*"'
if ($Text -match $opPattern) {
Add-Fail "boundary" "$RelativePath contains forbidden operation evidence: $($Matches[0])"
}
}
function Write-Report([string]$Path, [object[]]$Rows, [string]$Decision, [int]$PassCount, [int]$WarnCount, [int]$FailCount) {
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("# N12R Return Package Validation Report") | Out-Null
$lines.Add("") | Out-Null
$lines.Add("- Decision: $Decision") | Out-Null
$lines.Add("- PASS: $PassCount") | Out-Null
$lines.Add("- WARN: $WarnCount") | Out-Null
$lines.Add("- FAIL: $FailCount") | Out-Null
$lines.Add("") | Out-Null
$lines.Add("| Status | Category | Message |") | Out-Null
$lines.Add("|---|---|---|") | Out-Null
foreach ($row in $Rows) {
$message = ([string]$row.Message).Replace("|", "\|")
$category = ([string]$row.Category).Replace("|", "\|")
$lines.Add("| $($row.Status) | $category | $message |") | Out-Null
}
$parent = Split-Path -Parent $Path
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
Set-Content -LiteralPath $Path -Value $lines -Encoding UTF8
}
$packageFullPath = $null
$reportFullPath = $null
$canWriteReport = $false
try {
$packageFullPath = Resolve-AbsolutePath $PackageRoot
if (-not (Test-Path -LiteralPath $packageFullPath -PathType Container)) {
Add-Fail "package" "PackageRoot does not exist or is not a directory: $packageFullPath"
}
else {
$packageFullPath = (Resolve-Path -LiteralPath $packageFullPath).Path
Add-Pass "package" "PackageRoot exists: $packageFullPath"
}
if ($ReportPath) {
$reportFullPath = Resolve-AbsolutePath $ReportPath
if ($null -ne $packageFullPath) {
$packagePrefix = Ensure-TrailingSeparator $packageFullPath
if ($reportFullPath.StartsWith($packagePrefix, [System.StringComparison]::OrdinalIgnoreCase) -or
$reportFullPath.Equals($packageFullPath, [System.StringComparison]::OrdinalIgnoreCase)) {
Add-Fail "package" "ReportPath must be outside PackageRoot to keep the returned evidence package immutable: $reportFullPath"
}
else {
$canWriteReport = $true
Add-Pass "package" "ReportPath is outside PackageRoot: $reportFullPath"
}
}
}
if ((Test-Path -LiteralPath $packageFullPath -PathType Container)) {
foreach ($relative in $requiredFiles) {
$path = Join-PackagePath $packageFullPath $relative
if (Test-Path -LiteralPath $path -PathType Leaf) {
Add-Pass "required-files" "$relative exists"
}
else {
Add-Fail "required-files" "$relative is missing"
}
}
foreach ($relative in $optionalFiles) {
$path = Join-PackagePath $packageFullPath $relative
if (Test-Path -LiteralPath $path -PathType Leaf) {
Add-Pass "optional-files" "$relative exists"
}
else {
Add-Warn "optional-files" "$relative is not present"
}
}
foreach ($relative in $jsonFiles) {
$path = Join-PackagePath $packageFullPath $relative
if (Test-Path -LiteralPath $path -PathType Leaf) {
[void](Read-JsonFile $packageFullPath $relative)
}
}
foreach ($relative in ($requiredFiles + $optionalFiles)) {
if ($relative -eq "metadata/process-list.txt") {
continue
}
$path = Join-PackagePath $packageFullPath $relative
if (Test-Path -LiteralPath $path -PathType Leaf) {
$text = if ($rawFiles.ContainsKey($relative)) { $rawFiles[$relative] } else { Read-TextFile $packageFullPath $relative }
Test-ForbiddenEvidence $relative $text
}
}
$sourceNotes = Read-TextFile $packageFullPath "metadata/source-notes.txt"
if ($null -ne $sourceNotes) {
if ($sourceNotes -match '(?i)(internal|intranet|内网)') {
Add-Pass "metadata" "metadata/source-notes.txt states internal/intranet context"
}
else {
Add-Fail "metadata" "metadata/source-notes.txt must state this came from an internal-network test sandbox"
}
if ($sourceNotes -match '(?i)(sandbox|沙盒)') {
Add-Pass "metadata" "metadata/source-notes.txt states sandbox context"
}
else {
Add-Fail "metadata" "metadata/source-notes.txt must state sandbox context"
}
if ($sourceNotes -match '(?i)(human|manual|operator|interactive|人工|手工)') {
Add-Pass "metadata" "metadata/source-notes.txt states human/manual operation context"
}
else {
Add-Warn "metadata" "metadata/source-notes.txt does not clearly state human/manual operation context"
}
}
$version = if ($parsedJson.ContainsKey("helper/version.json")) { $parsedJson["helper/version.json"] } else { $null }
if ($null -ne $version) {
if (Test-OkField $version) {
Add-Pass "helper" "helper/version.json ok=true"
}
else {
Add-Fail "helper" "helper/version.json must have ok=true"
}
$versionData = Get-PropertyValue $version @("data")
$helperName = Get-PropertyValue $versionData @("helper_name", "helperName", "name")
if ($helperName -eq "ISphereWinHelper") {
Add-Pass "helper" "helper/version.json reports ISphereWinHelper"
}
else {
Add-Fail "helper" "helper/version.json must report helper_name=ISphereWinHelper"
}
}
$selfCheck = if ($parsedJson.ContainsKey("helper/self-check.json")) { $parsedJson["helper/self-check.json"] } else { $null }
if ($null -ne $selfCheck) {
if (Test-OkField $selfCheck) {
Add-Pass "helper" "helper/self-check.json ok=true"
}
else {
Add-Fail "helper" "helper/self-check.json must have ok=true"
}
$selfData = Get-PropertyValue $selfCheck @("data")
$uiaAvailable = Get-PropertyValue $selfData @("uia_available", "uiaAvailable", "uia")
if ([bool]$uiaAvailable) {
Add-Pass "helper" "helper/self-check.json indicates UIA availability"
}
else {
Add-Fail "helper" "helper/self-check.json must indicate UIA availability"
}
}
$scan = if ($parsedJson.ContainsKey("windows/scan-windows.json")) { $parsedJson["windows/scan-windows.json"] } else { $null }
$scanWindows = @()
if ($null -ne $scan) {
if (Test-OkField $scan) {
Add-Pass "windows" "windows/scan-windows.json ok=true"
}
else {
Add-Fail "windows" "windows/scan-windows.json must have ok=true"
}
$scanWindows = @(Convert-ToArray (Get-NestedValue $scan @("data", "windows")))
if ($scanWindows.Count -eq 0) {
$scanWindows = @(Convert-ToArray (Get-PropertyValue $scan @("windows")))
}
if ($scanWindows.Count -gt 0) {
Add-Pass "windows" "windows/scan-windows.json contains $($scanWindows.Count) window row(s)"
}
else {
Add-Fail "windows" "windows/scan-windows.json must contain data.windows"
}
$candidateCount = 0
foreach ($window in $scanWindows) {
$title = Format-Value (Get-PropertyValue $window @("title", "name"))
$process = Format-Value (Get-PropertyValue $window @("process_name", "processName", "process", "exe"))
$className = Format-Value (Get-PropertyValue $window @("class_name", "className"))
$haystack = "$title $process $className"
if ($haystack -match '(?i)(isphere|IMPlatformClient|IMPlatform)') {
$candidateCount += 1
}
}
if ($candidateCount -gt 0) {
Add-Pass "windows" "windows/scan-windows.json includes $candidateCount iSphere/IMPlatform candidate window(s)"
}
else {
Add-Fail "windows" "windows/scan-windows.json must include an iSphere/IMPlatform candidate window"
}
}
$selected = if ($parsedJson.ContainsKey("windows/selected-window.json")) { $parsedJson["windows/selected-window.json"] } else { $null }
if ($null -ne $selected) {
$selectedHwnd = Format-Value (Get-PropertyValue $selected @("hwnd", "handle"))
$selectedTitle = Format-Value (Get-PropertyValue $selected @("title", "name"))
$selectedProcess = Format-Value (Get-PropertyValue $selected @("process_name", "processName", "process", "exe"))
if ($selectedHwnd) {
Add-Pass "windows" "windows/selected-window.json records hwnd=$selectedHwnd"
}
else {
Add-Fail "windows" "windows/selected-window.json must record hwnd"
}
if ($selectedTitle -or $selectedProcess) {
Add-Pass "windows" "windows/selected-window.json records title/process metadata"
}
else {
Add-Fail "windows" "windows/selected-window.json must record title or process metadata"
}
$matchFound = $false
foreach ($window in $scanWindows) {
$windowHwnd = Format-Value (Get-PropertyValue $window @("hwnd", "handle"))
if ($selectedHwnd -and $windowHwnd -and $selectedHwnd -ieq $windowHwnd) {
$matchFound = $true
break
}
}
if ($scanWindows.Count -gt 0) {
if ($matchFound) {
Add-Pass "windows" "selected hwnd appears in scan-windows"
}
else {
Add-Warn "windows" "selected hwnd was not found in scan-windows"
}
}
}
$uiaMain = if ($parsedJson.ContainsKey("uia/dump-uia-main.json")) { $parsedJson["uia/dump-uia-main.json"] } else { $null }
if ($null -ne $uiaMain) {
if ((Has-Property $uiaMain "ok") -and -not (Test-OkField $uiaMain)) {
Add-Fail "uia" "uia/dump-uia-main.json has ok=false"
}
else {
Add-Pass "uia" "uia/dump-uia-main.json is a successful or root-level UIA response"
}
$root = Get-UiRoot $uiaMain
$nodeCount = Count-UiNodes $root
if ($nodeCount -gt 0) {
Add-Pass "uia" "uia/dump-uia-main.json contains UIA root/tree with $nodeCount node(s)"
}
else {
Add-Fail "uia" "uia/dump-uia-main.json must contain a UIA root/tree"
}
}
$uiaRedacted = if ($parsedJson.ContainsKey("uia/dump-uia-main-redacted.json")) { $parsedJson["uia/dump-uia-main-redacted.json"] } else { $null }
if ($null -ne $uiaRedacted) {
$root = Get-UiRoot $uiaRedacted
$nodeCount = Count-UiNodes $root
if ($nodeCount -gt 0) {
Add-Pass "uia" "uia/dump-uia-main-redacted.json contains parseable UIA root/tree with $nodeCount node(s)"
}
else {
Add-Fail "uia" "uia/dump-uia-main-redacted.json must contain a UIA root/tree"
}
if ($rawFiles.ContainsKey("uia/dump-uia-main.json") -and $rawFiles.ContainsKey("uia/dump-uia-main-redacted.json")) {
if ($rawFiles["uia/dump-uia-main.json"] -eq $rawFiles["uia/dump-uia-main-redacted.json"]) {
Add-Warn "uia" "redacted UIA dump is byte-identical to main dump; manually confirm no sensitive text is present"
}
else {
Add-Pass "uia" "redacted UIA dump differs from main dump"
}
}
}
}
}
catch {
Add-Fail "validator" "Unexpected validator error: $($_.Exception.Message)"
}
$passCount = @($results | Where-Object { $_.Status -eq "PASS" }).Count
$warnCount = @($results | Where-Object { $_.Status -eq "WARN" }).Count
$failCount = @($results | Where-Object { $_.Status -eq "FAIL" }).Count
$decision = if ($failCount -eq 0) { "ACCEPT" } else { "REJECT" }
foreach ($row in $results) {
Write-Host ("[{0}] {1}: {2}" -f $row.Status, $row.Category, $row.Message)
}
Write-Host ("Summary: PASS={0} WARN={1} FAIL={2}" -f $passCount, $warnCount, $failCount)
Write-Host "Decision: $decision"
if ($ReportPath -and $canWriteReport -and $null -ne $reportFullPath) {
try {
Write-Report -Path $reportFullPath -Rows ([object[]]$results.ToArray()) -Decision $decision -PassCount $passCount -WarnCount $warnCount -FailCount $failCount
Write-Host "Report: $reportFullPath"
}
catch {
Write-Host "[FAIL] report: could not write report: $($_.Exception.Message)"
exit 1
}
}
if ($failCount -gt 0) {
exit 1
}
exit 0

View File

@@ -0,0 +1,37 @@
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "go was not found. Run this verifier in a Go 1.23.x environment."
}
Write-Host "## go test ./internal/uiaselector"
go test ./internal/uiaselector -count=1
if ($LASTEXITCODE -ne 0) {
throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
}
Write-Host "## boundary grep"
$forbidden = @(
"send_after_approval",
"send_file_after_approval",
"receive_file_after_approval",
"open_conversation",
"ValuePattern.SetValue",
"InvokePattern"
)
$files = @(
"internal\uiaselector\catalog.go",
"internal\uiaselector\matcher.go"
)
foreach ($term in $forbidden) {
$hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
if ($hit) {
throw "forbidden action term found in selector implementation: $term"
}
}
Write-Host "N13 UIA selector verification passed."