feat: add send message preview contract

This commit is contained in:
zhaoyilun
2026-07-10 14:04:33 +08:00
parent 1f99681348
commit ac3f10f53c
12 changed files with 686 additions and 55 deletions

View File

@@ -14,7 +14,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
## Current completed state
- Go MCP foundation exists: `cmd/isphere-mcp`, `internal/mcpserver`, `internal/helperclient`, and `internal/tools` build a stdio MCP server.
- The current MCP surface exposes eight read-oriented tools:
- The current MCP surface exposes nine tools: four WinHelper observation tools, four read-oriented business tools, and one send-message preview-only tool:
- `win_helper_version`
- `win_helper_self_check`
- `win_helper_scan_windows`
@@ -23,22 +23,24 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_files`
- `isphere_send_message` preview/dry-run only; production is blocked
- C# `ISphereWinHelper` exists under `native/ISphereWinHelper` and uses the `isphere.helper.v1` stdin/stdout JSON contract. Helper `0.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls` for R6a/C31 send connector preflight.
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, eight-tool MCP surface, default empty-source receive/contact/group/file calls, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
- `scripts/verify-win-helper.ps1` and `scripts/verify-go-mcp.ps1` provide repeatable local checks for the helper, nine-tool MCP surface, default empty-source receive/contact/group/file calls, send-message preview/production-blocked behavior, synthetic configured-file receive/contact/group/file smoke, and synthetic configured-directory receive/contact/group/file smoke.
- `isphere_receive_messages` now returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope while retaining legacy parser-native message fields for compatibility.
- `isphere_receive_messages` accepts the read-contract argument set for the current local-readonly path: `conversation_id`, `query`, `since`, `limit`, `include_attachment_metadata`, `source_preference`, `preview`, and empty `cursor`.
- `isphere_search_contacts` and `isphere_search_groups` accept and validate the safe search-contract args for the current local-readonly path and can optionally enrich results from the env-configured C29 `MsgLib.db` display-entity reader; default behavior remains log/JID-backed when MsgLib env vars are absent.
- `isphere_receive_files` list mode accepts and validates the safe file-list contract args for the current local-readonly path while keeping download blocked.
- `isphere_send_message` accepts the R6b preview/dry-run contract (`target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode`) and returns planned preview metadata plus redacted audit metadata; `execution_mode="production"` returns blocked status with all side-effect flags false.
- `native/MsgLibReadSidecar` provides a bounded x86 .NET read-only boundary for `MsgLib.db` schema/display-source checks, bounded contact/group display-entity reads, message-source metadata, and sidecar-level bounded message listing; `internal/msglib` wraps it from Go, C30 wires display metadata as an optional MCP contact/group enrichment source, C39 adds a Go receive-source adapter around `ListMessages`, C40 adds tool-level explicit `msglib_readonly` source selection, and C41 wires it through env configuration without changing the MCP default source.
- N12-pre and N12R documents define safe offline evidence intake and internal-sandbox live UIA capture procedures.
- N13/N14/N15 produced selector catalog/report validation infrastructure, but those are supporting validation assets only.
## What cannot be claimed externally
- The project has not yet implemented business MCP tools for message sending, file download, or file sending; `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode have operator-local encrypted log-file/log-directory support but not a production ingestion path.
- The project has not yet implemented production message sending, file download, or file sending; `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode have operator-local encrypted log-file/log-directory support but not a production ingestion path.
- The project has not completed production iSphere integration.
- The project has not proven a stable real message source for the full business workflow beyond decrypted PacketReader log evidence and synthetic/redacted tests.
- The project has not implemented real message sending or real file upload. R6a confirms metadata/control preflight only; it does not prove send invocation, success/ack, or idempotent production behavior.
- The project has not implemented real message sending or real file upload. R6b adds preview/dry-run only; it does not prove send invocation, success/ack, or idempotent production behavior.
- N12-pre offline evidence is not N12 pass.
- N13/N14/N15 selector/report work is pre-business evidence, not finished contact/message/file capability.
@@ -47,7 +49,7 @@ Remote base before local roadmap commits: `b2d839e Merge branch 'codex/n15-repor
- The repository has a Go MCP + C# WinHelper foundation for controlled Windows-side read-only observation.
- The helper protocol boundary is explicit and keeps Windows/UIA work separate from the Go MCP service layer.
- The current code can support future MCP business tools by routing low-level Windows/UIA fallback work through a bounded helper layer.
- The project has repeatable verification scripts for the helper/MCP foundation and the current eight-tool surface.
- The project has repeatable verification scripts for the helper/MCP foundation and the current nine-tool surface.
- The project now has a corrected business contract and plan for core MCP communication tools, plus registered receive/contact/group/file-list read tools that can use `ISPHERE_PACKET_LOG_FILE` for one operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` for an operator-local directory of encrypted `.log`/`.txt` PacketReader files.
## N13/N14/N15 positioning
@@ -56,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
## Current loop
Current loop: `R6a/C31 non-mutating send connector preflight complete; next R6b B-route preview/dry-run contract`.
Current loop: `R6b B-route preview/dry-run contract complete; next R6c online sandbox-send evidence gate`.
Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
@@ -112,6 +114,7 @@ Current loop output so far:
41. C41: wired env-configured MsgLib client into both display enrichment and explicit receive selection; optional copied-DB MCP smoke now proves `source_preference="msglib_readonly"` returns sanitized count/source evidence while standard `verify-go-mcp.ps1` remains deterministic.
42. R5b/C30-live: analyzed returned live-probe package `isphere-live-probe-20260710-121958`; confirmed one x86 `IMPlatformClient.exe` `4.1.2.6842`, live server endpoint evidence, installed B-route DLL presence, active local `MsgLib.db` path shape, and A-route UIA selectors for search/draft/send/file menu. Production send remains blocked because the probe did not prove runtime entrypoint reachability or send success/ack mapping and the captured chat panels report an offline-send blocker.
43. R6a/C31: added WinHelper `0.4.0` read-only `probe_send_entrypoints` and `probe_send_uia_controls`, wired the live recorder to emit `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`, and verified that copied install metadata reports all 5 required B-route entrypoints available while the synthetic UIA classifier reports `A_ROUTE_OFFLINE_BLOCKED`. Production send/file upload remains blocked pending preview/dry-run, dynamic observation, idempotency/audit, and one approved sandbox send.
44. R6b: added `isphere_send_message` preview/dry-run only. The tool validates `target_type`, `target_id`, `content_sha256`, and `idempotency_key`, returns connector metadata `implatform-sidecar`/`preview`, appends redacted JSONL audit events without raw message body or raw idempotency key, and returns `send_status="blocked"` for production with all side-effect flags false. `verify-go-mcp.ps1` now verifies the 9-tool surface and proves `send_preview_tool_present=true` and `production_send_enabled=false`.
## Recompiled business roadmap
@@ -172,3 +175,13 @@ R6a/C31 non-mutating send connector preflight is complete:
- Local copied-install metadata reports `required_available_count=5`, `all_required_available=true`, and `b_route_entrypoint_preflight="pass"`.
- Synthetic UIA smoke reports `A_ROUTE_OFFLINE_BLOCKED`, proving the classifier can preserve the offline blocker as a first-class route state.
- Decision: next node is R6b B-route preview/dry-run contract. Keep production `isphere_send_message` and `isphere_send_file` blocked until a later online sandbox send proves success/ack and idempotency behavior.
R6b B-route preview/dry-run contract is complete:
- Added `internal/tools/isphere_send_message.go` and tests.
- Registered `isphere_send_message` in the Go MCP server as preview/dry-run only.
- Preview returns `ok=true`, `send_status="planned"`, target ref, content SHA256, idempotency key SHA256, connector metadata, audit metadata, and all side-effect flags false.
- Production returns `ok=false`, `send_status="blocked"`, a clear blocked reason, and all side-effect flags false.
- `ISPHERE_SEND_AUDIT_PATH` can override the local audit JSONL path; the default path is ignored under `runs/send-audit/send-message-preview.jsonl`.
- Standard MCP smoke now verifies 9 tools and confirms production send is not enabled.
- Decision: next node is R6c online sandbox-send evidence gate. Do not implement production send until one approved sandbox send proves success/ack or sent-record evidence and duplicate idempotency protection.

View File

@@ -2,7 +2,7 @@
This runbook is for the first Go MCP phase of `isphere-ai-bridge`. It lets an operator build and verify the Windows helper and the Go MCP server without reading the source code.
Current scope: expose four WinHelper observation operations plus four business read tools through Go MCP: `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode. These business read tools use an empty default message source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` points to an operator-local directory of encrypted PacketReader `.log`/`.txt` files. Contact/group display names can also be optionally enriched from an operator-local copied `MsgLib.db` through the bounded read-only `MsgLibReadSidecar`; receive messages can use copied DB rows only when the caller explicitly passes `source_preference="msglib_readonly"`.
Current scope: expose four WinHelper observation operations, four business read tools, and one preview-only send-message tool through Go MCP. Read tools are `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files` list mode. The send tool is `isphere_send_message` preview/dry-run only; production send is blocked. Read tools use an empty default message source unless `ISPHERE_PACKET_LOG_FILE` points to an operator-local encrypted PacketReader log-line file or `ISPHERE_PACKET_LOG_DIR` points to an operator-local directory of encrypted PacketReader `.log`/`.txt` files. Contact/group display names can also be optionally enriched from an operator-local copied `MsgLib.db` through the bounded read-only `MsgLibReadSidecar`; receive messages can use copied DB rows only when the caller explicitly passes `source_preference="msglib_readonly"`.
## 1. Prerequisites
@@ -43,7 +43,7 @@ Run the helper verification script:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1
```
Expected output includes `"ok":true`, helper version `0.1.0`, a window scan count, and a UIA availability value.
Expected output includes `"ok":true`, helper version `0.4.0`, a window scan count, send preflight smoke fields, and a UIA availability value.
## 4. Build Go MCP
@@ -90,7 +90,7 @@ The verification confirms:
- C# helper can be built.
- Go MCP binary can be built.
- MCP initialize/list/call flow works through the SDK harness.
- Exactly eight tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, and `isphere_receive_files`.
- Exactly nine tools are exposed: four WinHelper observation tools plus `isphere_receive_messages`, `isphere_search_contacts`, `isphere_search_groups`, `isphere_receive_files`, and preview-only `isphere_send_message`.
- `win_helper_version` returns `ISphereWinHelper`.
- `isphere_receive_messages` is callable and returns an empty `messages` array from the default empty source.
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
@@ -99,9 +99,10 @@ The verification confirms:
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
- `isphere_send_message` is callable in preview mode and returns `send_status="planned"` without raw content in the response; `execution_mode="production"` returns `send_status="blocked"` and `production_send_enabled=false`.
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
- File download/cache mapping and send business tools remain later-stage work.
- File download/cache mapping, send-file, and production send remain later-stage work.
## 6. Configure optional message source
@@ -237,7 +238,7 @@ E:\coding\codex\isphere-ai-bridge\isphere-mcp.exe
## 9. Allowed tools
These eight tools are currently allowed:
These nine tools are currently allowed:
| Tool | Source/op | Purpose |
| --- | --- | --- |
@@ -249,6 +250,7 @@ These eight tools are currently allowed:
| `isphere_search_contacts` | log-backed message JIDs plus optional MsgLib display enrichment | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_search_groups` | log-backed groupchat JIDs plus optional MsgLib display enrichment | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. |
| `isphere_send_message` | B-route preview/dry-run contract | Plan a text-message send with target/content hash/idempotency/audit metadata. Production send is blocked. |
Allowed parameters:
@@ -260,10 +262,11 @@ Allowed parameters:
- `isphere_search_contacts`: `query`, `cursor`, `source_preference`, `include_inactive`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
- `isphere_search_groups`: `query`, `cursor`, `source_preference`, `include_archived`, `limit` only. `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` must be empty until pagination is implemented.
- `isphere_receive_files`: `conversation_id`, `message_id`, `file_id`, `name_contains`, `mode`, `output_dir`, `cursor`, `source_preference`, `preview`, `limit` only. C22 supports `mode` empty or `list`; `source_preference` currently supports empty/`auto`/`local_readonly`; `cursor` and `output_dir` must be empty in current list-only mode; download is deferred.
- `isphere_send_message`: `target_type`, `target_id`, `content_text`, `content_sha256`, `idempotency_key`, `execution_mode` only. R6b supports `execution_mode` empty/`preview`/`dry_run` as planned preview. `execution_mode="production"` returns blocked status until sandbox-send evidence passes. The response and audit store `content_sha256` and `idempotency_key_sha256`, not raw message body or raw idempotency key.
## 10. Current tool surface and next-stage route
The current runbook verifies eight tools:
The current runbook verifies nine tools:
- `win_helper_version`
- `win_helper_self_check`
@@ -273,10 +276,11 @@ The current runbook verifies eight tools:
- `isphere_search_contacts`
- `isphere_search_groups`
- `isphere_receive_files`
- `isphere_send_message`
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred.
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. `isphere_send_message` supports preview/dry-run metadata and redacted audit only; production send remains blocked until dynamic observation, idempotency protection, and one approved sandbox send prove success/ack behavior.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send-message preview metadata and audit records are now in place; production send still waits for the later online sandbox-send gate.
## 11. Real-login UIA capture procedure

View File

@@ -264,19 +264,20 @@ Errors: `INVALID_ARGUMENT`, `SOURCE_UNAVAILABLE`, `NOT_LOGGED_IN`, `PERMISSION_D
| --- | --- |
| Business purpose | Send a text message to a resolved contact or group. |
| Read-only | No. |
| Execution mode | Preview or production write. |
| Current priority | Stage D write capability. |
| Execution mode | Preview/dry-run now; production write after sandbox-send gate. |
| Current priority | R6b preview/dry-run contract is implemented; production remains blocked. |
Input parameters:
```json
{
"target_type": "contact|group|conversation, required",
"target_type": "direct|group, required",
"target_id": "string, required",
"content_text": "string, required",
"content_sha256": "sha256 hex of content_text, required",
"idempotency_key": "string, required; stored only as SHA256 in audit",
"execution_mode": "preview|production, optional, default preview",
"idempotency_key": "string, optional but recommended",
"source_preference": "bridge|uia|network|auto, optional, default auto"
"source_preference": "not accepted in R6b preview-only implementation"
}
```
@@ -286,18 +287,37 @@ Output JSON:
{
"ok": true,
"execution_mode": "preview",
"send_status": "planned|sent|rejected",
"send_status": "planned|blocked",
"connector": "implatform-sidecar",
"connector_stage": "preview|blocked",
"target": {
"target_type": "contact|group|conversation",
"target_type": "direct|group",
"target_id": "string",
"display_name": "string|null"
"target_ref": "contact:<id>|group:<id>"
},
"content": {
"content_sha256": "sha256 hex",
"content_length": 12
},
"idempotency": {
"idempotency_key_sha256": "sha256 hex"
},
"side_effects": {
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false
},
"message_ref": null,
"content_sha256": "sha256 hex",
"audit": {}
}
```
R6b rule: the response and audit may include `content_sha256`, `content_length`, `idempotency_key_sha256`, `target_ref`, connector metadata, and side-effect booleans. They must not include raw message body or raw idempotency key. `execution_mode="production"` returns `ok=false` and `send_status="blocked"` until dynamic observation, idempotency/audit verification, and one approved sandbox send pass.
Errors: `INVALID_ARGUMENT`, `WRITE_NOT_READY`, `PERMISSION_DENIED`, `NOT_LOGGED_IN`, `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `PREVIEW_ONLY`, `TIMEOUT`, `CONNECTOR_FAILED`, `INTERNAL_ERROR`.
## Tool: `isphere_send_file`

View File

@@ -114,14 +114,8 @@ R6a passes as a non-mutating preflight:
## Next implementation node
Next node: **R6b B-route preview/dry-run contract**.
R6b B-route preview/dry-run contract is complete. `isphere_send_message` now plans sends, validates content SHA256 and idempotency key, writes redacted audit metadata, and blocks production.
R6b should implement only preview/dry-run and audit shape first:
Next node: **R6c online sandbox-send evidence gate**.
1. target resolution input shape from existing contact/group search result refs;
2. `content_sha256` / file hash and idempotency key validation;
3. connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`;
4. local audit append for preview attempts;
5. no real send, no file upload, and no UI click/type.
The later online-only gate remains: one operator-approved sandbox send must still prove success/ack or sent-record evidence before any production send path is exposed.
R6c should collect or prepare the one approved sandbox-send evidence package needed before production can be exposed. It must still prove success/ack or sent-record evidence and duplicate idempotency protection before any production send path is enabled.

View File

@@ -344,18 +344,17 @@ connector_stage = "preview" | "sandbox_send" | "production"
## Next implementation loop
R6a/C31 non-mutating send connector preflight is now complete. WinHelper `0.4.0` adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`; the live recorder now emits `send_entrypoints_preflight.json` and `send_uia_controls_preflight.json`; copied install metadata reports all required B-route entrypoints available; and the UIA classifier can report offline-blocked state.
R6b B-route preview/dry-run contract is now complete. `isphere_send_message` is registered as preview/dry-run only, validates target/content-hash/idempotency input, appends redacted audit JSONL, and blocks production with all side-effect flags false.
Next node should be **R6b B-route preview/dry-run contract**:
Next node should be **R6c online sandbox-send evidence gate**:
1. Keep `isphere_send_message` in preview/dry-run only.
2. Resolve target refs from existing contact/group search output.
3. Validate `content_sha256` and `idempotency_key`.
4. Return connector metadata such as `connector="implatform-sidecar"` and `connector_stage="preview"`.
5. Append a local audit event for preview attempts.
6. Do not send, click, type, upload, attach hooks, or capture network traffic in this node.
7. Keep the later online-only sandbox-send gate separate.
1. Prepare or collect one operator-approved sandbox send evidence package.
2. Use one explicit sandbox target, one content hash, and one idempotency key.
3. Observe success/ack or local sent-record evidence.
4. Prove duplicate retry with the same idempotency key is locally rejected before any second send.
5. Capture method names, argument shape, IDs/hashes, status booleans, and refs only.
6. Do not enable production `send_message_after_approval` until the evidence gate passes.
## Current status
B is selected as the primary send/file-send route. Runtime discovery and entrypoint metadata preflight have passed enough to continue to preview/dry-run design. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until dynamic observation, idempotent dry-run/audit, and one approved sandbox send prove success/ack behavior.
B is selected as the primary send/file-send route. Runtime discovery, entrypoint metadata preflight, and preview/dry-run audit are in place. A remains the fallback and now has concrete UIA selector/offline-blocker classifier support. Production send remains blocked until dynamic observation and one approved sandbox send prove success/ack behavior.

View File

@@ -24,8 +24,8 @@ Send connector preflight: `docs/source-discovery/2026-07-10-send-connector-prefl
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | MsgLib `TD_ReceiveFileRecord` safe metadata through explicit copied-DB path; decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck`; `docs/source-discovery/2026-07-10-file-download-mapping-precheck.md`; `docs/source-discovery/2026-07-10-file-cache-mapping-diagnostic.md` | C22 verifies safe file-list contract args; R3 confirms list metadata is available; R3b diagnostic finds 0 current cache/archive matches, so R4 remains blocked | blocked pending better cache evidence or UI/client download connector |
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 adds read-only `probe_send_entrypoints` and `probe_send_uia_controls`. Copied install metadata reports all 5 required B-route send entrypoints available, including `SendTxtMessageByJid`, `MessageScheduling.SendChatMessage`, `Chat.SendMessage`, and file-transfer candidates. UIA classifier can report offline-blocked state. This is preflight only: no real send, success/ack, or idempotent production behavior is proven. | next: R6b B-route preview/dry-run contract and audit shape; production send remains blocked |
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)` | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind the message-send preview/dry-run contract plus later upload/file-message dry-run and sandbox evidence. | next: wait for R6b message-send preview/dry-run, then file-send upload preflight |
| `isphere_send_message` | B-route selected: running-client sidecar / in-process connector, preferring `AppContextManager.SendTxtMessageByJid(...)` or `MessageScheduling.SendChatMessage(...)` over the unimplemented `SendP2PMessage(...)` interface path; current MCP tool exposes preview/dry-run only | A-route fallback: constrained UI/RPA wrapper only if B cannot pass runtime probe, entrypoint availability, dynamic observation, and idempotent dry-run gates; network/protocol replay remains deferred | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md`; `internal/tools/isphere_send_message_test.go` | R6b registers `isphere_send_message` preview/dry-run only. Preview validates target/content hash/idempotency, returns `send_status="planned"`, connector metadata, redacted audit metadata, and all side-effect flags false. Production returns `send_status="blocked"` and `production_send_enabled=false` in verification. No real send, success/ack, or production idempotency behavior is proven. | next: R6c online sandbox-send evidence gate; production send remains blocked |
| `isphere_send_file` | B-route selected after send-message probe: managed offline-file path through `OffLineFileSend.sendFile()`, `IMPP.Service*.dll` `IFileTransfer.FileUpload(...)`, and `Chat.sendFileMessage(...)` | A-route fallback: constrained UI/RPA upload/send-file flow; native `TcpFileTransfer.dll` is a later candidate only after managed path is rejected | `docs/source-discovery/2026-07-10-send-message-source-precheck.md`; `docs/source-discovery/2026-07-10-send-message-connector-selection.md`; `docs/source-discovery/2026-07-10-send-sidecar-b-first-plan.md`; `docs/source-discovery/2026-07-10-returned-live-probe-analysis.md`; `docs/source-discovery/2026-07-10-send-connector-preflight.md` | R6a/C31 metadata confirms `FileTransfer.FileUpload`, `FileTransfer.AsynFileUpload`, `OffLineFileSend.sendFile`, and `Chat.sendFileMessage` candidates. File-send remains blocked behind message-send sandbox evidence plus later upload/file-message dry-run evidence. | next: wait for R6c sandbox-send evidence, then file-send upload preflight |
## Stage C entry rule
@@ -42,4 +42,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac
## Deferred source work
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6b B-route preview/dry-run contract.
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R6c online sandbox-send evidence gate.

View File

@@ -28,9 +28,9 @@
| Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
| Send messages | Contract exists; R6a/C31 read-only preflight confirms B-route entrypoint metadata and A-route UIA/offline-state classifier support, but not production send. | Need preview/dry-run contract, idempotency/audit, dynamic observation, and one approved sandbox send before production. | next R6b B-route preview/dry-run contract |
| Send messages | `isphere_send_message` preview/dry-run is registered; R6a confirms B-route entrypoint metadata and A-route UIA/offline-state classifier support. | Need dynamic observation and one approved sandbox send before production. | next R6c online sandbox-send evidence gate |
| Receive files | List mode exists from message-derived file metadata; R3/R3b confirm DB file metadata exists but current cache/archive evidence does not map to local files. | R4 download is blocked until better cache evidence or a UI/client download connector is available. | blocked; continue R5 |
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind send-message preview/dry-run plus upload/file-transfer dry-run and sandbox evidence. | wait for R6b, then file-send upload preflight |
| Send files | Contract exists; R6a/C31 metadata confirms file-upload/offline-file/file-message candidates and A-route file menu classifier support. | Blocked behind send-message sandbox evidence plus upload/file-transfer dry-run. | wait for R6c, then file-send upload preflight |
## Node Sequence
@@ -439,11 +439,46 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
**Stop condition:**
- If target refs cannot be represented without raw local evidence values, stop and add a target-ref design note before coding production-facing arguments.
**R6b Result:**
- Added preview-only `isphere_send_message` in `internal/tools/isphere_send_message.go`.
- Registered the tool in `internal/mcpserver/server.go`.
- Added TDD tests proving preview returns `send_status="planned"` with no raw content leak, production returns `send_status="blocked"` with no side effects, and content hash mismatch is rejected.
- Updated `scripts/verify-go-mcp.ps1` to verify the 9-tool MCP surface, send preview behavior, and `production_send_enabled=false`.
- Preview audit appends JSONL through `ISPHERE_SEND_AUDIT_PATH` or the default ignored `runs/send-audit/send-message-preview.jsonl` path. Audit stores content hash and idempotency-key hash, not raw body or raw key.
- Decision: next node is R6c online sandbox-send evidence gate. Production send and send-file remain blocked.
---
### R6c: Online sandbox-send evidence gate
**Purpose:** Collect or prepare the minimum online sandbox evidence needed before enabling any production send path.
**Files:**
- Create or modify: `docs/source-discovery/2026-07-10-send-sandbox-gate.md`
- Modify optional recorder/helper scripts only if a non-mutating or operator-approved sandbox-send evidence package is required.
- Modify docs: this roadmap, `docs/current-status-card.md`, and `docs/source-discovery/capability-source-matrix.md`.
**Evidence required:**
- One explicit sandbox target only.
- One content hash only.
- One idempotency key only.
- Observed success/ack or local sent-record evidence.
- Duplicate retry with the same idempotency key is rejected locally.
- Captured evidence contains method names, argument shape, IDs/hashes, status booleans, and refs only; no raw message body needs to be committed.
**Acceptance gate:**
- A clear decision says whether production `isphere_send_message` can start, remains blocked, or needs a new online package.
- No production send behavior is enabled by this node unless the evidence gate has passed.
**Stop condition:**
- If the environment cannot log in or cannot use a sandbox target, stop and request a returned sandbox evidence package rather than implementing production send.
---
### R6: Send-message preview and production path
**Purpose:** Implement production `isphere_send_message` after R6b preview/dry-run exists and a later approved sandbox-send gate proves success/ack behavior.
**Purpose:** Implement production `isphere_send_message` after R6c proves success/ack behavior.
**Files:**
- Create or modify: `internal/tools/isphere_send_message.go`

View File

@@ -111,5 +111,6 @@ func NewServerWithSourcesAndMsgLibReceive(source tools.ReceiveMessagesSource, di
tools.RegisterISphereContactToolsWithDisplayEntities(server, source, displaySource)
tools.RegisterISphereGroupToolsWithDisplayEntities(server, source, displaySource)
tools.RegisterISphereFileTools(server, source)
tools.RegisterISphereSendMessageTool(server, nil)
return server
}

View File

@@ -25,7 +25,7 @@ func TestNewServerConstructsMCPServer(t *testing.T) {
}
}
func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
func TestNewServerRegistersNineToolsWithoutCallingHelper(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server := NewServer()
@@ -60,7 +60,7 @@ func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) {
gotNames = append(gotNames, tool.Name)
}
sort.Strings(gotNames)
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "isphere_send_message", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames)
}

View File

@@ -0,0 +1,303 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
ToolNameSendMessage = "isphere_send_message"
EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH"
sendMessageConnector = "implatform-sidecar"
sendMessagePreviewStage = "preview"
sendMessageBlockedStage = "blocked"
sendMessageProductionMode = "production"
)
type SendMessageArgs struct {
TargetType string `json:"target_type" jsonschema:"target type; direct or group"`
TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"`
ContentText string `json:"content_text" jsonschema:"message body to hash and preview; not stored in audit"`
ContentSHA256 string `json:"content_sha256" jsonschema:"lowercase SHA256 hex of content_text"`
IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; stored only as SHA256 in audit"`
ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview or production; production is blocked until sandbox-send gate passes"`
}
type SendMessageAuditSink interface {
AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error
}
type SendMessageAuditEvent struct {
Tool string `json:"tool"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetRef string `json:"target_ref"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
ContentSHA256 string `json:"content_sha256"`
ContentLength int `json:"content_length"`
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
SendStatus string `json:"send_status"`
Result string `json:"result"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
SideEffects map[string]any `json:"side_effects"`
ContentText string `json:"-"`
IdempotencyKey string `json:"-"`
}
type fileSendMessageAuditSink struct {
path string
}
func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) {
if auditSink == nil {
auditSink = defaultSendMessageAuditSink()
}
mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{
Name: ToolNameSendMessage,
Description: "Preview iSphere message sends with idempotency/audit metadata; production send is blocked until sandbox evidence passes.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendMessageArgs) (*mcp.CallToolResult, map[string]any, error) {
started := time.Now().UTC()
normalized, err := normalizeSendMessageArgs(input)
if err != nil {
return nil, nil, err
}
finished := time.Now().UTC()
response, event := sendMessagePreviewResponse(normalized, started, finished)
if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil {
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err)
}
return nil, response, nil
})
}
func defaultSendMessageAuditSink() SendMessageAuditSink {
if path := strings.TrimSpace(os.Getenv(EnvSendMessageAuditPath)); path != "" {
return fileSendMessageAuditSink{path: path}
}
return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")}
}
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
if strings.TrimSpace(s.path) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(append(payload, '\n')); err != nil {
return err
}
return nil
}
type normalizedSendMessageArgs struct {
TargetType string
TargetID string
TargetRef string
ContentLength int
ContentSHA256 string
IdempotencyKeySHA256 string
ExecutionMode string
}
func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, error) {
targetType, targetRefPrefix, err := normalizeSendMessageTargetType(input.TargetType)
if err != nil {
return normalizedSendMessageArgs{}, err
}
targetID := strings.TrimSpace(input.TargetID)
if targetID == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s target_id is required", ToolNameSendMessage)
}
contentText := input.ContentText
if strings.TrimSpace(contentText) == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s content_text is required", ToolNameSendMessage)
}
contentHash, err := validateSendMessageContentHash(contentText, input.ContentSHA256)
if err != nil {
return normalizedSendMessageArgs{}, err
}
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
if idempotencyKey == "" {
return normalizedSendMessageArgs{}, fmt.Errorf("%s idempotency_key is required", ToolNameSendMessage)
}
mode, err := normalizeSendMessageExecutionMode(input.ExecutionMode)
if err != nil {
return normalizedSendMessageArgs{}, err
}
return normalizedSendMessageArgs{
TargetType: targetType,
TargetID: targetID,
TargetRef: targetRefPrefix + ":" + targetID,
ContentLength: len(contentText),
ContentSHA256: contentHash,
IdempotencyKeySHA256: sha256Hex(idempotencyKey),
ExecutionMode: mode,
}, nil
}
func normalizeSendMessageTargetType(value string) (string, string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "direct", "contact":
return "direct", "contact", nil
case "group", "groupchat":
return "group", "group", nil
default:
return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendMessage, value)
}
}
func validateSendMessageContentHash(contentText string, provided string) (string, error) {
actual := sha256Hex(contentText)
normalized := strings.ToLower(strings.TrimSpace(provided))
if normalized == "" {
return "", fmt.Errorf("%s content_sha256 is required", ToolNameSendMessage)
}
if len(normalized) != 64 || !isLowerHex(normalized) {
return "", fmt.Errorf("%s content_sha256 must be a lowercase SHA256 hex value", ToolNameSendMessage)
}
if normalized != actual {
return "", fmt.Errorf("%s content_sha256 does not match content_text", ToolNameSendMessage)
}
return actual, nil
}
func normalizeSendMessageExecutionMode(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "preview", "dry_run", "dry-run":
return "preview", nil
case sendMessageProductionMode:
return sendMessageProductionMode, nil
default:
return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendMessage, value)
}
}
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time) (map[string]any, SendMessageAuditEvent) {
sideEffects := sendMessageNoSideEffects()
status := "planned"
stage := sendMessagePreviewStage
ok := true
blockedReason := any(nil)
if input.ExecutionMode == sendMessageProductionMode {
ok = false
status = "blocked"
stage = sendMessageBlockedStage
blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
}
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
event := SendMessageAuditEvent{
Tool: ToolNameSendMessage,
TargetType: input.TargetType,
TargetID: input.TargetID,
TargetRef: input.TargetRef,
ExecutionMode: input.ExecutionMode,
Connector: sendMessageConnector,
ConnectorStage: stage,
ContentSHA256: input.ContentSHA256,
ContentLength: input.ContentLength,
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
SendStatus: status,
Result: status,
AuditRef: auditRef,
StartedAt: started.Format(time.RFC3339Nano),
FinishedAt: finished.Format(time.RFC3339Nano),
SideEffects: sideEffects,
}
response := map[string]any{
"ok": ok,
"send_status": status,
"blocked_reason": blockedReason,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_stage": stage,
"target": map[string]any{
"target_type": input.TargetType,
"target_id": input.TargetID,
"target_ref": input.TargetRef,
},
"content": map[string]any{
"content_sha256": input.ContentSHA256,
"content_length": input.ContentLength,
},
"idempotency": map[string]any{
"idempotency_key_sha256": input.IdempotencyKeySHA256,
},
"side_effects": sideEffects,
"audit": map[string]any{
"tool": ToolNameSendMessage,
"source": sendMessageConnector,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_stage": stage,
"content_sha256": input.ContentSHA256,
"target_ref": input.TargetRef,
"audit_ref": auditRef,
"started_at": started.Format(time.RFC3339Nano),
"finished_at": finished.Format(time.RFC3339Nano),
"result": status,
},
}
return response, event
}
func sendMessageNoSideEffects() map[string]any {
return map[string]any{
"sent_message": false,
"sent_file": false,
"uploaded_file": false,
"clicked_ui": false,
"typed_text": false,
"captured_network": false,
"attached_hook": false,
"modified_client_data": false,
}
}
func sendMessageAuditRef(contentSHA256 string, idempotencySHA256 string) string {
return "send-message:" + shortHash(contentSHA256) + ":" + shortHash(idempotencySHA256)
}
func shortHash(value string) string {
if len(value) < 12 {
return value
}
return value[:12]
}
func sha256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func isLowerHex(value string) bool {
for _, ch := range value {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
return false
}
}
return true
}

View File

@@ -0,0 +1,180 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, audit)
})
defer cleanup()
content := "hello preview"
contentHash := sha256HexForSendMessageTest(content)
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "idem-preview-1",
"execution_mode": "preview",
},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
ExecutionMode string `json:"execution_mode"`
Connector string `json:"connector"`
ConnectorStage string `json:"connector_stage"`
Target map[string]any `json:"target"`
Content map[string]any `json:"content"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if !decoded.OK || decoded.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
t.Fatalf("unexpected preview status: %s", payload)
}
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
t.Fatalf("unexpected connector fields: %s", payload)
}
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
t.Fatalf("unexpected target: %#v", decoded.Target)
}
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
}
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
t.Fatalf("preview response leaked raw content body: %s", payload)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
}
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
}
if len(audit.events) != 1 {
t.Fatalf("audit events = %+v, want one", audit.events)
}
event := audit.events[0]
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
t.Fatalf("unexpected audit event: %+v", event)
}
if event.ContentText != "" || event.IdempotencyKey != "" {
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
}
}
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, audit)
})
defer cleanup()
contentHash := sha256HexForSendMessageTest("blocked send")
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "group",
"target_id": "project-room@conference.imopenfire1-lanzhou",
"content_text": "blocked send",
"content_sha256": contentHash,
"idempotency_key": "idem-production-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
}
if callResult.IsError {
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
BlockedReason string `json:"blocked_reason"`
Target map[string]any `json:"target"`
SideEffects map[string]any `json:"side_effects"`
Audit map[string]any `json:"audit"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
t.Fatalf("production was not clearly blocked: %s", payload)
}
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
t.Fatalf("unexpected target: %#v", decoded.Target)
}
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
}
if decoded.Audit["result"] != "blocked" {
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
}
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
}
}
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{})
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSendMessage,
Arguments: map[string]any{
"target_type": "direct",
"target_id": "alice@imopenfire1-lanzhou",
"content_text": "hash mismatch",
"content_sha256": sha256HexForSendMessageTest("different body"),
"idempotency_key": "idem-hash-mismatch",
"execution_mode": "preview",
},
})
if err == nil && !callResult.IsError {
t.Fatalf("hash mismatch was accepted")
}
}
type fakeSendMessageAuditSink struct {
events []SendMessageAuditEvent
}
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
f.events = append(f.events, event)
return nil
}
func sha256HexForSendMessageTest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}

View File

@@ -10,7 +10,8 @@ $expectedTools = @(
"isphere_receive_messages",
"isphere_search_contacts",
"isphere_search_groups",
"isphere_receive_files"
"isphere_receive_files",
"isphere_send_message"
)
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("isphere-go-mcp-verify-" + [guid]::NewGuid().ToString("N"))
@@ -62,7 +63,9 @@ import (
"context"
"crypto/cipher"
"crypto/des"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -86,9 +89,10 @@ var expectedTools = []string{
"isphere_search_contacts",
"isphere_search_groups",
"isphere_receive_files",
"isphere_send_message",
}
var forbiddenTerms = []string{"send", "login", "conversation", "upload", "download", "autologin"}
var forbiddenTerms = []string{"login", "upload", "autologin"}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
@@ -143,7 +147,7 @@ func main() {
sort.Strings(toolNames)
want := append([]string(nil), expectedTools...)
sort.Strings(want)
if len(toolNames) != 8 || !reflect.DeepEqual(toolNames, want) {
if len(toolNames) != 9 || !reflect.DeepEqual(toolNames, want) {
fail("tools/list", fmt.Errorf("tool names = %#v, want %#v", toolNames, want))
}
@@ -205,6 +209,7 @@ func main() {
defaultContactCount := verifySearchContacts(ctx, session, "sender", "tools/call isphere_search_contacts default", 0)
defaultGroupCount := verifySearchGroups(ctx, session, "project", "tools/call isphere_search_groups default", 0)
defaultFileCount := verifyReceiveFiles(ctx, session, "report", "tools/call isphere_receive_files default", 0, "", "")
sendPreviewOK, productionSendEnabled := verifySendMessagePreview(ctx, session)
configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx)
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
@@ -232,6 +237,8 @@ func main() {
"real_isphere_login_required": false,
"python_runtime_required": false,
"action_tools_present": false,
"send_preview_tool_present": sendPreviewOK,
"production_send_enabled": productionSendEnabled,
"msglib_configured": false,
}
encoded, _ := json.Marshal(result)
@@ -485,6 +492,72 @@ func verifyConfiguredDirectorySource(ctx context.Context) (int, int, int, int) {
return len(messagesValue), contactCount, groupCount, fileCount
}
func verifySendMessagePreview(ctx context.Context, session *mcp.ClientSession) (bool, bool) {
content := "redacted preview body"
contentHash := sha256HexForHarness(content)
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_message", Arguments: map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "verify-preview-idempotency-key",
"execution_mode": "preview",
}})
if err != nil {
fail("tools/call isphere_send_message preview", err)
}
if callResult.IsError {
fail("tools/call isphere_send_message preview", fmt.Errorf("tool returned isError=true: %#v", callResult.Content))
}
var previewPayload map[string]any
encoded, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(encoded, &previewPayload); err != nil {
fail("tools/call isphere_send_message preview", fmt.Errorf("decode structuredContent %s: %w", encoded, err))
}
if previewPayload["ok"] != true || previewPayload["send_status"] != "planned" || previewPayload["connector"] != "implatform-sidecar" || previewPayload["connector_stage"] != "preview" {
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected preview status: %s", encoded))
}
contentValue, ok := previewPayload["content"].(map[string]any)
if !ok || contentValue["content_sha256"] != contentHash {
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected content metadata: %s", encoded))
}
if _, exists := contentValue["content_text"]; exists {
fail("tools/call isphere_send_message preview", fmt.Errorf("preview leaked raw content: %s", encoded))
}
sideEffects, ok := previewPayload["side_effects"].(map[string]any)
if !ok || sideEffects["sent_message"] != false || sideEffects["clicked_ui"] != false || sideEffects["captured_network"] != false {
fail("tools/call isphere_send_message preview", fmt.Errorf("unexpected side effects: %s", encoded))
}
productionResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_message", Arguments: map[string]any{
"target_type": "direct",
"target_id": "sender@imopenfire1-lanzhou",
"content_text": content,
"content_sha256": contentHash,
"idempotency_key": "verify-production-idempotency-key",
"execution_mode": "production",
}})
if err != nil {
fail("tools/call isphere_send_message production_blocked", err)
}
if productionResult.IsError {
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("tool returned isError=true: %#v", productionResult.Content))
}
var productionPayload map[string]any
productionEncoded, _ := json.Marshal(productionResult.StructuredContent)
if err := json.Unmarshal(productionEncoded, &productionPayload); err != nil {
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("decode structuredContent %s: %w", productionEncoded, err))
}
if productionPayload["ok"] != false || productionPayload["send_status"] != "blocked" || productionPayload["connector_stage"] != "blocked" {
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("production was not blocked: %s", productionEncoded))
}
productionSideEffects, ok := productionPayload["side_effects"].(map[string]any)
if !ok || productionSideEffects["sent_message"] != false || productionSideEffects["typed_text"] != false || productionSideEffects["uploaded_file"] != false {
fail("tools/call isphere_send_message production_blocked", fmt.Errorf("unexpected production side effects: %s", productionEncoded))
}
return true, false
}
func verifySearchContacts(ctx context.Context, session *mcp.ClientSession, query string, step string, wantCount int) int {
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_search_contacts", Arguments: map[string]any{
"query": query,
@@ -627,6 +700,11 @@ func encryptPacketLogLineForHarness(plaintext string) (string, error) {
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func sha256HexForHarness(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func padPKCS7ForHarness(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
out := append([]byte(nil), data...)
@@ -663,7 +741,7 @@ func fail(step string, err error) {
Assert-True ($script:harnessJson.ok -eq $true) "SDK harness did not return ok=true"
Assert-True ($script:harnessJson.helper_name -eq "ISphereWinHelper") "SDK harness helper_name mismatch: $($script:harnessJson.helper_name)"
Assert-True ($script:harnessJson.tool_count -eq 8) "SDK harness tool_count mismatch: $($script:harnessJson.tool_count)"
Assert-True ($script:harnessJson.tool_count -eq 9) "SDK harness tool_count mismatch: $($script:harnessJson.tool_count)"
Assert-True ($script:harnessJson.receive_message_count -eq 0) "SDK harness receive_message_count mismatch: $($script:harnessJson.receive_message_count)"
Assert-True ($script:harnessJson.contact_count -eq 0) "SDK harness contact_count mismatch: $($script:harnessJson.contact_count)"
Assert-True ($script:harnessJson.group_count -eq 0) "SDK harness group_count mismatch: $($script:harnessJson.group_count)"
@@ -676,6 +754,8 @@ func fail(step string, err error) {
Assert-True ($script:harnessJson.configured_dir_contact_count -eq 1) "SDK harness configured_dir_contact_count mismatch: $($script:harnessJson.configured_dir_contact_count)"
Assert-True ($script:harnessJson.configured_dir_group_count -eq 1) "SDK harness configured_dir_group_count mismatch: $($script:harnessJson.configured_dir_group_count)"
Assert-True ($script:harnessJson.configured_dir_file_count -eq 1) "SDK harness configured_dir_file_count mismatch: $($script:harnessJson.configured_dir_file_count)"
Assert-True ($script:harnessJson.send_preview_tool_present -eq $true) "SDK harness send_preview_tool_present mismatch: $($script:harnessJson.send_preview_tool_present)"
Assert-True ($script:harnessJson.production_send_enabled -eq $false) "SDK harness production_send_enabled mismatch: $($script:harnessJson.production_send_enabled)"
[ordered]@{
ok = $true
@@ -696,6 +776,8 @@ func fail(step string, err error) {
configured_dir_contact_count = $script:harnessJson.configured_dir_contact_count
configured_dir_group_count = $script:harnessJson.configured_dir_group_count
configured_dir_file_count = $script:harnessJson.configured_dir_file_count
send_preview_tool_present = $script:harnessJson.send_preview_tool_present
production_send_enabled = $script:harnessJson.production_send_enabled
packet_log_file_configured = $script:harnessJson.packet_log_file_configured
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
python_runtime_required = $false