diff --git a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md index 7f3331d..a39ceb0 100644 --- a/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md +++ b/docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md @@ -402,7 +402,7 @@ Implemented and verified in this loop: - Consumes: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`. - Produces: `EncryptedPacketLogSource.ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)`. -- [ ] **Step 1: Write failing source abstraction test** +- [x] **Step 1: Write failing source abstraction test** Create `internal/isphere/source_test.go`: @@ -482,7 +482,7 @@ go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMes Expected: FAIL because `EncryptedPacketLogSource`, `ReceiveMessagesQuery`, and related types do not exist. -- [ ] **Step 2: Implement source abstraction** +- [x] **Step 2: Implement source abstraction** Create `internal/isphere/source.go`: @@ -584,7 +584,7 @@ func conversationID(sender string, receiver string) string { } ``` -- [ ] **Step 3: Verify source abstraction test passes** +- [x] **Step 3: Verify source abstraction test passes** Run: @@ -594,7 +594,7 @@ go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMes Expected: PASS. -- [ ] **Step 4: Run full verification** +- [x] **Step 4: Run full verification** Run: @@ -605,7 +605,7 @@ go build ./cmd/isphere-mcp Expected: both exit 0. -- [ ] **Step 5: Commit Loop C2** +- [x] **Step 5: Commit Loop C2** Run: @@ -617,23 +617,82 @@ git commit -m "feat: add log-backed receive message source" Expected: commit succeeds. -- [ ] **Step 6: Rewrite Loop C3 before continuing** +- [x] **Step 6: Rewrite Loop C3 before continuing** After Loop C2 passes, update `Loop C3` with the exact source interface and MCP response shape from the committed implementation. --- +## Loop C2 Result + +Implemented and verified in this loop: + +- `internal/isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, query)` decrypts encrypted PacketReader log lines, parses XMPP `` stanzas, normalizes bare sender/receiver JIDs, and returns C2 `isphere.Message` records. +- `ReceiveMessagesQuery{Limit: n}` limits returned messages when `n > 0`; zero or negative limit returns all available lines. +- The source checks context cancellation before each decrypt/parse operation and returns `ctx.Err()` when canceled. +- Tests use synthetic/redacted encrypted PacketReader fixture data only; no raw N12-pre evidence was committed. +- Verification completed for C2 with `git diff --check`, `go test ./...`, and `go build ./cmd/isphere-mcp`; the generated `isphere-mcp.exe` build artifact was removed. +- Loop C3 was rewritten around the actual C2 interface and the planned MCP response shape before continuing. + +--- + ## Loop C3: Register `isphere_receive_messages` MCP tool -**Current draft goal:** Add the first business MCP tool after the source abstraction passes tests. +**Goal:** Register the first business read MCP tool on top of the C2 source abstraction, while keeping helper tools read-only and leaving send/search/file operations out of scope. **Planned files:** - Create: `internal/tools/isphere_read.go` - Create: `internal/tools/isphere_read_test.go` - Modify: `internal/mcpserver/server.go` +- Modify: `internal/mcpserver/server_test.go` - Modify: `internal/tools/winhelper_test.go` +- Modify: `docs/source-discovery/capability-source-matrix.md` +- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md` -**Rewrite rule before implementation:** Update existing tests that currently assert only four helper tools. The new expected surface should be four helper tools plus `isphere_receive_messages`. +**Interfaces from Loop C2:** +- Tool name: `isphere_receive_messages`. +- Tool input: `limit` only. +- Tool source: `isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})`. +- Tool output shape: a map/object with `messages`, where each message carries `id`, `conversation_id`, `conversation_type`, `sender_id`, `receiver_id`, `text`, `timestamp`, `subject`, `receipt_id`, and `read`. + +- [ ] **Step 1: Write failing tool registration tests** + +Add or update tests so they fail before implementation: + +```powershell +go test ./internal/tools -run TestISphereReceiveMessagesToolReturnsSourceMessages -v +go test ./internal/mcpserver -run TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper -v +``` + +Expected initial failures: `isphere_receive_messages` is not registered and the server/tool lists still expose only the four helper tools. + +- [ ] **Step 2: Implement `internal/tools/isphere_read.go`** + +Register `isphere_receive_messages` with `mcp.AddTool`, convert the C2 `isphere.Message` slice into the explicit JSON/map output shape, and keep the only public input parameter as `limit`. Use a source interface so tests can inject a synthetic C2 source without raw evidence files. + +- [ ] **Step 3: Register business read tool in `mcpserver.NewServer`** + +Register the helper tools plus `isphere_receive_messages`. If no live log-line loader exists yet, wire an empty `isphere.EncryptedPacketLogSource{}` as the default so registration is testable without committing raw logs. + +- [ ] **Step 4: Update existing helper-tool guard tests** + +Rename or adjust the old “exactly four” helper test so it still blocks forbidden write/sending tool names but explicitly allows `isphere_receive_messages`. Update `internal/mcpserver/server_test.go` to expect five tools: four helper tools plus `isphere_receive_messages`. + +- [ ] **Step 5: Run full verification** + +Run: + +```powershell +git diff --check +go test ./... +go build ./cmd/isphere-mcp +``` + +Expected: all exit 0. + +- [ ] **Step 6: Commit Loop C3 and rewrite Loop C4** + +Commit exact C3 paths only. Rewrite Loop C4 around the actual MCP tool output shape and the fact that default server wiring has no raw evidence loader yet. --- diff --git a/internal/isphere/source.go b/internal/isphere/source.go new file mode 100644 index 0000000..4ce7d5c --- /dev/null +++ b/internal/isphere/source.go @@ -0,0 +1,95 @@ +package isphere + +import ( + "context" + "fmt" + "strings" + + "isphere-ai-bridge/internal/isphere/logcodec" + "isphere-ai-bridge/internal/isphere/packetlog" +) + +type Message struct { + ID string + ConversationID string + ConversationType string + SenderID string + ReceiverID string + Text string + Timestamp string + Subject string + ReceiptID string + Read bool +} + +type ReceiveMessagesQuery struct { + Limit int +} + +type ReceiveMessagesResult struct { + Messages []Message +} + +type EncryptedPacketLogSource struct { + Lines []string +} + +func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) { + limit := query.Limit + if limit <= 0 || limit > len(s.Lines) { + limit = len(s.Lines) + } + messages := make([]Message, 0, limit) + for index, line := range s.Lines { + if len(messages) == limit { + break + } + select { + case <-ctx.Done(): + return ReceiveMessagesResult{}, ctx.Err() + default: + } + plaintext, err := logcodec.DecryptLine(line) + if err != nil { + return ReceiveMessagesResult{}, fmt.Errorf("decrypt packet log line %d: %w", index, err) + } + parsed, err := packetlog.ParseMessageLog(plaintext) + if err != nil { + return ReceiveMessagesResult{}, fmt.Errorf("parse packet log line %d: %w", index, err) + } + messages = append(messages, normalizePacketMessage(parsed)) + } + return ReceiveMessagesResult{Messages: messages}, nil +} + +func normalizePacketMessage(msg packetlog.Message) Message { + sender := bareJID(msg.From) + receiver := bareJID(msg.To) + return Message{ + ID: msg.ID, + ConversationID: conversationID(sender, receiver), + ConversationType: msg.Type, + SenderID: sender, + ReceiverID: receiver, + Text: msg.Body, + Timestamp: msg.SendTime, + Subject: msg.Subject, + ReceiptID: msg.ReceiptID, + Read: msg.Read, + } +} + +func bareJID(value string) string { + beforeResource, _, _ := strings.Cut(value, "/") + return beforeResource +} + +func conversationID(sender string, receiver string) string { + if sender == "" { + return receiver + } + if receiver == "" { + return sender + } + return sender + "|" + receiver +} diff --git a/internal/isphere/source_test.go b/internal/isphere/source_test.go new file mode 100644 index 0000000..d8385a5 --- /dev/null +++ b/internal/isphere/source_test.go @@ -0,0 +1,65 @@ +package isphere + +import ( + "context" + "crypto/cipher" + "crypto/des" + "encoding/base64" + "testing" +) + +func TestEncryptedPacketLogSourceReturnsNormalizedMessages(t *testing.T) { + plaintext := `-------------------------------------------------------------------------------------------------------------------------------------------- +2026/7/7 15:30:07 + + redacted + + FILE_TRANSFER_CANCEL + + +` + source := EncryptedPacketLogSource{Lines: []string{encryptPacketLineForTest(t, plaintext)}} + + got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{Limit: 10}) + if err != nil { + t.Fatalf("ReceiveMessages returned error: %v", err) + } + if len(got.Messages) != 1 { + t.Fatalf("message count = %d, want 1", len(got.Messages)) + } + msg := got.Messages[0] + if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" { + t.Fatalf("unexpected message content: %+v", msg) + } + if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" { + t.Fatalf("conversation id = %q", msg.ConversationID) + } + if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" { + t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID) + } + if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read { + t.Fatalf("metadata = %+v", msg) + } +} + +func encryptPacketLineForTest(t *testing.T, plaintext string) string { + t.Helper() + block, err := des.NewCipher([]byte("hyhccdtm")) + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + plain := padPacketLineForTest([]byte(plaintext), des.BlockSize) + ciphertext := make([]byte, len(plain)) + iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF} + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain) + return base64.StdEncoding.EncodeToString(ciphertext) +} + +func padPacketLineForTest(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + out := append([]byte(nil), data...) + for i := 0; i < pad; i++ { + out = append(out, byte(pad)) + } + return out +}