Compare commits
13 Commits
codex/isph
...
codex/n13-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe2ce5d64 | ||
|
|
d8dabada57 | ||
|
|
27c87d318d | ||
|
|
4d5a9b0ae8 | ||
|
|
d648c9476d | ||
|
|
a7b41ccb15 | ||
|
|
5b5d33140b | ||
|
|
b2b1c0c968 | ||
|
|
19d05fe3c9 | ||
|
|
55c749b0ab | ||
|
|
22e4e8f8ce | ||
|
|
08516ed35a | ||
|
|
c54f7c447a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,3 +18,9 @@ obj/
|
||||
dist/
|
||||
__pycache__/
|
||||
.venv/
|
||||
|
||||
# Local handoff prompt
|
||||
/给接手人的交接话术.md
|
||||
|
||||
# Local intake scratch directory
|
||||
/temp/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
217
docs/internal-sandbox-live-capture-plan.md
Normal file
217
docs/internal-sandbox-live-capture-plan.md
Normal 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.
|
||||
387
docs/internal-sandbox-operator-runbook.md
Normal file
387
docs/internal-sandbox-operator-runbook.md
Normal 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 标记为通过。
|
||||
219
docs/n12r-final-handoff-checklist.md
Normal file
219
docs/n12r-final-handoff-checklist.md
Normal 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.
|
||||
341
docs/n12r-internal-operator-minimal-retry-2026-07-09.md
Normal file
341
docs/n12r-internal-operator-minimal-retry-2026-07-09.md
Normal 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=True,score=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 设计。
|
||||
|
||||
223
docs/n12r-return-package-validation-checklist.md
Normal file
223
docs/n12r-return-package-validation-checklist.md
Normal 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.
|
||||
@@ -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、读内存或规避安防。
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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 只读工具。
|
||||
148
internal/uiaselector/catalog.go
Normal file
148
internal/uiaselector/catalog.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
60
internal/uiaselector/catalog_test.go
Normal file
60
internal/uiaselector/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
109
internal/uiaselector/dump.go
Normal file
109
internal/uiaselector/dump.go
Normal 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())
|
||||
}
|
||||
19
internal/uiaselector/dump_test.go
Normal file
19
internal/uiaselector/dump_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
186
internal/uiaselector/matcher.go
Normal file
186
internal/uiaselector/matcher.go
Normal 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),
|
||||
}
|
||||
}
|
||||
73
internal/uiaselector/matcher_test.go
Normal file
73
internal/uiaselector/matcher_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
vendored
Normal file
1
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
vendored
Normal file
File diff suppressed because one or more lines are too long
189
scripts/test-validate-n12r-return-package.ps1
Normal file
189
scripts/test-validate-n12r-return-package.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
540
scripts/validate-n12r-return-package.ps1
Normal file
540
scripts/validate-n12r-return-package.ps1
Normal 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
|
||||
37
scripts/verify-n13-uia-selectors.ps1
Normal file
37
scripts/verify-n13-uia-selectors.ps1
Normal 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."
|
||||
Reference in New Issue
Block a user