diff --git a/docs/current-status-card.md b/docs/current-status-card.md index bff07d6..19f209f 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -240,8 +240,8 @@ Continuous execution plan R6f-R14 is approved and execution has started: - Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. - Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report. - Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action. -- Last completed round: R7 send-file preview tool. -- Next active round: R8 send-file idempotency and audit hardening. +- Last completed round: R8 send-file idempotency and audit hardening. +- Next active round: R9 send-file upload evidence package. R6g send audit and idempotency replay diagnostics is complete: @@ -305,3 +305,13 @@ R7 send-file preview tool is complete: - `execution_mode="production"` returns structured blocked metadata; no upload, click, type, hook, network capture, or client mutation is introduced. - Verification passed: focused send-file tests, 10-tool MCP registration test, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`. - Next node: R8 send-file idempotency and audit hardening. + +R8 send-file idempotency and audit hardening is complete: + +- Added `ISPHERE_SEND_FILE_AUDIT_PATH` and `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` support. +- Duplicate same target/file/idempotency key returns `duplicate_detected=true` and remains no-send. +- Reusing the same idempotency key for a different target or file hash returns structured blocked metadata with `conflict_detected=true`. +- Send-file audit/idempotency JSONL stores hashes, target refs, size, result, and side-effect flags; it does not store raw file content, raw local file path, or raw idempotency key. +- Production file upload remains blocked with `production_send_enabled=false` and `real_send_attempted=false`. +- Verification passed: focused send-file tests, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`. +- Next node: R9 send-file upload evidence package. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 57029fa..3efdd55 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -284,7 +284,14 @@ Send-file preview requires an explicit allowed directory. Put only the files you $env:ISPHERE_SEND_FILE_ALLOWED_DIR = "E:\coding\codex\isphere-ai-bridge\runs\send-file-preview" ``` -`isphere_send_file` rejects `file_path` values outside that directory. It computes file SHA256 and size, but does not upload, click, type, or return file content. +Optional send-file audit/idempotency paths: + +```powershell +$env:ISPHERE_SEND_FILE_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-preview.jsonl" +$env:ISPHERE_SEND_FILE_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-idempotency.jsonl" +``` + +`isphere_send_file` rejects `file_path` values outside that directory. It computes file SHA256 and size, but does not upload, click, type, or return file content. If `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` is set, duplicate same-key/same-target/same-file preview calls set `duplicate_detected=true`; reusing the same key for a different target or file hash returns blocked metadata with `conflict_detected=true`. The audit/idempotency JSONL stores file hash, size, target ref, and hashed idempotency key; it does not store raw file content, raw local file path, or raw idempotency key. R6g adds a local diagnostic for replaying the redacted audit/idempotency files without opening iSphere: diff --git a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md index 10944d8..af387e3 100644 --- a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md +++ b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md @@ -594,19 +594,19 @@ git push gitea main - Duplicate same target/file/idempotency returns `duplicate_detected=true` with no second send. - Reusing the same idempotency key for a different target or file hash returns `conflict_detected=true`. -- [ ] **Step 1: Write duplicate test** +- [x] **Step 1: Write duplicate test** Add `TestISphereSendFileDuplicateIdempotencyDetected`: same target, same file hash, same idempotency key called twice; second response has `duplicate_detected=true` and `real_send_attempted=false`. -- [ ] **Step 2: Write conflict test** +- [x] **Step 2: Write conflict test** Add `TestISphereSendFileIdempotencyConflictBlocked`: same idempotency key with a different file hash returns `send_status="blocked"` and `conflict_detected=true`. -- [ ] **Step 3: Implement audit and idempotency state** +- [x] **Step 3: Implement audit and idempotency state** Reuse the message idempotency pattern or extract a shared small helper only if it reduces duplication without changing message behavior. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** ```powershell git diff --check @@ -621,6 +621,15 @@ git push gitea main **Acceptance gate:** File upload remains blocked, but duplicate/conflict behavior is deterministic. +**R8 Result:** + +- Added send-file audit and idempotency state with `ISPHERE_SEND_FILE_AUDIT_PATH` and `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH`. +- Duplicate same target/file/idempotency key now returns `duplicate_detected=true` and remains planned/no-send. +- Reusing the same idempotency key for a different target or file hash returns `send_status="blocked"` and `conflict_detected=true`. +- Send-file audit/idempotency JSONL stores file hash, size, target ref, result, side-effect flags, and hashed idempotency key; it does not store raw file content, raw local file path, or raw idempotency key. +- `scripts/verify-go-mcp.ps1` now asserts `send_file_duplicate_detected=true` and `send_file_conflict_blocked=true` while keeping `send_file_production_enabled=false`. +- Verification passed: `git diff --check`, `go test ./internal/tools -run TestISphereSendFile -v`, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`. + --- ### R9: Send-file upload evidence package diff --git a/internal/tools/isphere_send_file.go b/internal/tools/isphere_send_file.go index daab30b..3a10836 100644 --- a/internal/tools/isphere_send_file.go +++ b/internal/tools/isphere_send_file.go @@ -1,22 +1,27 @@ package tools import ( + "bufio" "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "os" "path/filepath" "strings" + "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) const ( - ToolNameSendFile = "isphere_send_file" - EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR" + ToolNameSendFile = "isphere_send_file" + EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR" + EnvSendFileAuditPath = "ISPHERE_SEND_FILE_AUDIT_PATH" + EnvSendFileIdempotencyPath = "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH" sendFileConnector = "implatform-file-transfer" sendFileConnectorMode = "preview-only" @@ -46,27 +51,226 @@ type normalizedSendFileArgs struct { ExecutionMode string } +type SendFileAuditSink interface { + AppendSendFileAudit(ctx context.Context, event SendFileAuditEvent) error +} + +type SendFileIdempotencyStore interface { + ReserveSendFileIdempotency(ctx context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error) +} + +type SendFileAuditEvent 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"` + ConnectorMode string `json:"connector_mode"` + ConnectorStage string `json:"connector_stage"` + FileSHA256 string `json:"file_sha256"` + FileSizeBytes int64 `json:"file_size_bytes"` + IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` + SendStatus string `json:"send_status"` + Result string `json:"result"` + BlockedReason string `json:"blocked_reason,omitempty"` + AuditRef string `json:"audit_ref"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RealSendAttempted bool `json:"real_send_attempted"` + SideEffects map[string]any `json:"side_effects"` + FilePath string `json:"-"` + IdempotencyKey string `json:"-"` +} + +type SendFileIdempotencyRecord struct { + Tool string `json:"tool"` + TargetRef string `json:"target_ref"` + ExecutionMode string `json:"execution_mode"` + Connector string `json:"connector"` + ConnectorStage string `json:"connector_stage"` + FileSHA256 string `json:"file_sha256"` + IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` + AuditRef string `json:"audit_ref"` + StartedAt string `json:"started_at"` +} + +type SendFileIdempotencyDecision struct { + Duplicate bool + Conflict bool + FirstAuditRef string + FirstStartedAt string +} + +type fileSendFileAuditSink struct { + path string +} + +type fileSendFileIdempotencyStore struct { + path string +} + +var sendFileIdempotencyFileMu sync.Mutex + func RegisterISphereSendFileTool(server *mcp.Server) { + RegisterISphereSendFileToolWithState(server, nil, nil) +} + +func RegisterISphereSendFileToolWithState(server *mcp.Server, auditSink SendFileAuditSink, idempotencyStore SendFileIdempotencyStore) { + if auditSink == nil { + auditSink = defaultSendFileAuditSink() + } + if idempotencyStore == nil { + idempotencyStore = defaultSendFileIdempotencyStore() + } + mcp.AddTool[SendFileArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameSendFile, Description: "Preview iSphere file sends with target/file hash/idempotency metadata; production file upload is blocked until sandbox evidence passes.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input SendFileArgs) (*mcp.CallToolResult, map[string]any, error) { - _ = ctx started := time.Now().UTC() normalized, blockedReason, err := normalizeSendFileArgs(input) if err != nil { return nil, nil, err } + + var response map[string]any + var event SendFileAuditEvent if blockedReason != "" { - return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason), nil + response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason) + } else if normalized.ExecutionMode == sendFileProductionMode { + response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes") + } else { + response, event = sendFileResponse(normalized, started, time.Now().UTC(), true, "") } - if normalized.ExecutionMode == sendFileProductionMode { - return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes"), nil + + if normalized.FileSHA256 != "" { + decision, err := idempotencyStore.ReserveSendFileIdempotency(ctx, sendFileIdempotencyRecordFromAuditEvent(event)) + if err != nil { + return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendFile, err) + } + applySendFileIdempotencyDecision(response, &event, decision) } - return nil, sendFileResponse(normalized, started, time.Now().UTC(), true, ""), nil + if err := auditSink.AppendSendFileAudit(ctx, event); err != nil { + return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendFile, err) + } + return nil, response, nil }) } +func defaultSendFileAuditSink() SendFileAuditSink { + if path := strings.TrimSpace(os.Getenv(EnvSendFileAuditPath)); path != "" { + return fileSendFileAuditSink{path: path} + } + return fileSendFileAuditSink{path: filepath.Join("runs", "send-audit", "send-file-preview.jsonl")} +} + +func defaultSendFileIdempotencyStore() SendFileIdempotencyStore { + if path := strings.TrimSpace(os.Getenv(EnvSendFileIdempotencyPath)); path != "" { + return fileSendFileIdempotencyStore{path: path} + } + return fileSendFileIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-file-idempotency.jsonl")} +} + +func (s fileSendFileAuditSink) AppendSendFileAudit(_ context.Context, event SendFileAuditEvent) 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 +} + +func (s fileSendFileIdempotencyStore) ReserveSendFileIdempotency(_ context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error) { + if strings.TrimSpace(s.path) == "" { + return SendFileIdempotencyDecision{}, nil + } + if record.IdempotencyKeySHA256 == "" { + return SendFileIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required") + } + sendFileIdempotencyFileMu.Lock() + defer sendFileIdempotencyFileMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return SendFileIdempotencyDecision{}, err + } + existing, err := readSendFileIdempotencyRecords(s.path) + if err != nil { + return SendFileIdempotencyDecision{}, err + } + for _, candidate := range existing { + if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 { + continue + } + decision := SendFileIdempotencyDecision{ + FirstAuditRef: candidate.AuditRef, + FirstStartedAt: candidate.StartedAt, + } + if candidate.TargetRef == record.TargetRef && candidate.FileSHA256 == record.FileSHA256 { + decision.Duplicate = true + return decision, nil + } + decision.Conflict = true + return decision, nil + } + + payload, err := json.Marshal(record) + if err != nil { + return SendFileIdempotencyDecision{}, err + } + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return SendFileIdempotencyDecision{}, err + } + defer file.Close() + if _, err := file.Write(append(payload, '\n')); err != nil { + return SendFileIdempotencyDecision{}, err + } + return SendFileIdempotencyDecision{}, nil +} + +func readSendFileIdempotencyRecords(path string) ([]SendFileIdempotencyRecord, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + + var records []SendFileIdempotencyRecord + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record SendFileIdempotencyRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return nil, fmt.Errorf("decode send-file idempotency record: %w", err) + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return records, nil +} + func normalizeSendFileArgs(input SendFileArgs) (normalizedSendFileArgs, string, error) { targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType) if err != nil { @@ -194,7 +398,7 @@ func sha256FileHex(path string) (string, error) { return hex.EncodeToString(hash.Sum(nil)), nil } -func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) map[string]any { +func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) (map[string]any, SendFileAuditEvent) { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendFilePreviewStage @@ -207,7 +411,30 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished blocked = blockedReason } auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256) - return map[string]any{ + event := SendFileAuditEvent{ + Tool: ToolNameSendFile, + TargetType: input.TargetType, + TargetID: input.TargetID, + TargetRef: input.TargetRef, + ExecutionMode: input.ExecutionMode, + Connector: sendFileConnector, + ConnectorMode: sendFileConnectorMode, + ConnectorStage: stage, + FileSHA256: input.FileSHA256, + FileSizeBytes: input.FileSizeBytes, + IdempotencyKeySHA256: input.IdempotencyKeySHA256, + SendStatus: status, + Result: status, + AuditRef: auditRef, + StartedAt: started.Format(time.RFC3339Nano), + FinishedAt: finished.Format(time.RFC3339Nano), + RealSendAttempted: false, + SideEffects: sideEffects, + } + if blockedReason != "" { + event.BlockedReason = blockedReason + } + response := map[string]any{ "ok": ok, "send_status": status, "blocked_reason": blocked, @@ -234,6 +461,10 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished }, "idempotency": map[string]any{ "idempotency_key_sha256": input.IdempotencyKeySHA256, + "duplicate_detected": false, + "conflict_detected": false, + "first_audit_ref": nil, + "first_started_at": nil, }, "side_effect_flags": sideEffects, "side_effects": sideEffects, @@ -254,4 +485,58 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished "real_send_attempted": false, }, } + return response, event +} + +func sendFileIdempotencyRecordFromAuditEvent(event SendFileAuditEvent) SendFileIdempotencyRecord { + return SendFileIdempotencyRecord{ + Tool: event.Tool, + TargetRef: event.TargetRef, + ExecutionMode: event.ExecutionMode, + Connector: event.Connector, + ConnectorStage: event.ConnectorStage, + FileSHA256: event.FileSHA256, + IdempotencyKeySHA256: event.IdempotencyKeySHA256, + AuditRef: event.AuditRef, + StartedAt: event.StartedAt, + } +} + +func applySendFileIdempotencyDecision(response map[string]any, event *SendFileAuditEvent, decision SendFileIdempotencyDecision) { + idempotency, _ := response["idempotency"].(map[string]any) + if idempotency == nil { + idempotency = map[string]any{} + response["idempotency"] = idempotency + } + idempotency["duplicate_detected"] = decision.Duplicate + idempotency["conflict_detected"] = decision.Conflict + if decision.FirstAuditRef != "" { + idempotency["first_audit_ref"] = decision.FirstAuditRef + } + if decision.FirstStartedAt != "" { + idempotency["first_started_at"] = decision.FirstStartedAt + } + if !decision.Conflict { + return + } + + blockedReason := "idempotency key was already used for a different target or file" + response["ok"] = false + response["send_status"] = "blocked" + response["blocked_reason"] = blockedReason + response["connector_stage"] = sendFileBlockedStage + + audit, _ := response["audit"].(map[string]any) + if audit != nil { + audit["result"] = "blocked" + audit["connector_stage"] = sendFileBlockedStage + audit["blocked_reason"] = blockedReason + } + + if event != nil { + event.ConnectorStage = sendFileBlockedStage + event.SendStatus = "blocked" + event.Result = "blocked" + event.BlockedReason = blockedReason + } } diff --git a/internal/tools/isphere_send_file_test.go b/internal/tools/isphere_send_file_test.go index e25918c..819a252 100644 --- a/internal/tools/isphere_send_file_test.go +++ b/internal/tools/isphere_send_file_test.go @@ -132,6 +132,161 @@ func TestISphereSendFileRejectsPathOutsideAllowedDir(t *testing.T) { } } +func TestISphereSendFileDuplicateIdempotencyDetected(t *testing.T) { + allowedDir := t.TempDir() + filePath := writeSendFileFixture(t, allowedDir, "duplicate.txt", "duplicate") + t.Setenv(EnvSendFileAllowedDir, allowedDir) + t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl")) + t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl")) + + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendFileTool(server) + }) + defer cleanup() + + args := map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "file_path": filePath, + "file_sha256": sha256HexForSendMessageTest("duplicate"), + "idempotency_key": "file-duplicate-idem-1", + "execution_mode": "preview", + "preview": true, + } + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args}); err != nil { + t.Fatalf("first send-file preview: %v", err) + } + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args}) + if err != nil { + t.Fatalf("second send-file preview: %v", err) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + RealSendAttempted bool `json:"real_send_attempted"` + Idempotency map[string]any `json:"idempotency"` + SideEffectFlags map[string]any `json:"side_effect_flags"` + } + payload, _ := json.Marshal(second.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode duplicate payload %s: %v", payload, err) + } + if !decoded.OK || decoded.SendStatus != "planned" || decoded.RealSendAttempted { + t.Fatalf("duplicate should remain planned preview/no-send: %s", payload) + } + if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false { + t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency) + } + if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false { + t.Fatalf("duplicate should have no file side effects: %#v", decoded.SideEffectFlags) + } +} + +func TestISphereSendFileIdempotencyConflictBlocked(t *testing.T) { + allowedDir := t.TempDir() + firstPath := writeSendFileFixture(t, allowedDir, "first.txt", "first") + secondPath := writeSendFileFixture(t, allowedDir, "second.txt", "second") + t.Setenv(EnvSendFileAllowedDir, allowedDir) + t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl")) + t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl")) + + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendFileTool(server) + }) + defer cleanup() + + sharedKey := "file-conflict-idem-1" + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "file_path": firstPath, + "file_sha256": sha256HexForSendMessageTest("first"), + "idempotency_key": sharedKey, + "execution_mode": "preview", + }}) + if err != nil { + t.Fatalf("first send-file preview: %v", err) + } + second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "file_path": secondPath, + "file_sha256": sha256HexForSendMessageTest("second"), + "idempotency_key": sharedKey, + "execution_mode": "preview", + }}) + if err != nil { + t.Fatalf("conflict send-file preview: %v", err) + } + + var decoded struct { + OK bool `json:"ok"` + SendStatus string `json:"send_status"` + BlockedReason string `json:"blocked_reason"` + RealSendAttempted bool `json:"real_send_attempted"` + Idempotency map[string]any `json:"idempotency"` + SideEffectFlags map[string]any `json:"side_effect_flags"` + } + payload, _ := json.Marshal(second.StructuredContent) + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode conflict payload %s: %v", payload, err) + } + if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" || decoded.RealSendAttempted { + t.Fatalf("conflict should be blocked/no-send: %s", payload) + } + if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true { + t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency) + } + if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["typed_text"] != false { + t.Fatalf("conflict should have no side effects: %#v", decoded.SideEffectFlags) + } +} + +func TestISphereSendFileAuditRedactionIsStable(t *testing.T) { + allowedDir := t.TempDir() + filePath := writeSendFileFixture(t, allowedDir, "secret.txt", "secret file bytes") + auditPath := filepath.Join(t.TempDir(), "send-file-audit.jsonl") + t.Setenv(EnvSendFileAllowedDir, allowedDir) + t.Setenv(EnvSendFileAuditPath, auditPath) + t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl")) + + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterISphereSendFileTool(server) + }) + defer cleanup() + + fileHash := sha256HexForSendMessageTest("secret file bytes") + idempotencyKey := "raw-file-idempotency-key-must-not-be-stored" + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{ + "target_type": "direct", + "target_id": "alice@imopenfire1-lanzhou", + "file_path": filePath, + "file_sha256": fileHash, + "idempotency_key": idempotencyKey, + "execution_mode": "preview", + }}) + if err != nil { + t.Fatalf("send-file preview: %v", err) + } + if callResult.IsError { + t.Fatalf("send-file preview should not be tool error: %+v", callResult) + } + payload, err := os.ReadFile(auditPath) + if err != nil { + t.Fatalf("read send-file audit: %v", err) + } + audit := string(payload) + for _, forbidden := range []string{filePath, idempotencyKey, "secret file bytes"} { + if strings.Contains(audit, forbidden) { + t.Fatalf("send-file audit leaked forbidden value %q: %s", forbidden, audit) + } + } + if !strings.Contains(audit, fileHash) { + t.Fatalf("send-file audit missing file hash: %s", audit) + } +} + func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) { allowedDir := t.TempDir() filePath := filepath.Join(allowedDir, "blocked.txt") @@ -185,3 +340,12 @@ func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) { t.Fatalf("production blocked should have no side effects: %#v", decoded.SideEffectFlags) } } + +func writeSendFileFixture(t *testing.T, dir string, name string, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write send-file fixture %s: %v", name, err) + } + return path +} diff --git a/scripts/verify-go-mcp.ps1 b/scripts/verify-go-mcp.ps1 index 3b30de0..f91a98c 100644 --- a/scripts/verify-go-mcp.ps1 +++ b/scripts/verify-go-mcp.ps1 @@ -100,7 +100,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() - for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_FILE_ALLOWED_DIR"} { + for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_FILE_ALLOWED_DIR", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} { if err := os.Unsetenv(key); err != nil { fail("server/env", err) } @@ -116,6 +116,12 @@ func main() { if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-idempotency.jsonl")); err != nil { fail("server/send_state", err) } + if err := os.Setenv("ISPHERE_SEND_FILE_AUDIT_PATH", filepath.Join(sendStateDir, "send-file-preview.jsonl")); err != nil { + fail("server/send_file_state", err) + } + if err := os.Setenv("ISPHERE_SEND_FILE_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-file-idempotency.jsonl")); err != nil { + fail("server/send_file_state", err) + } sendFileDir, err := os.MkdirTemp("", "isphere-send-file-*") if err != nil { fail("server/send_file_state", err) @@ -235,7 +241,7 @@ func main() { 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) - sendFilePreviewOK, sendFileProductionEnabled := verifySendFilePreview(ctx, session, sendFilePath) + sendFilePreviewOK, sendFileProductionEnabled, sendFileDuplicateDetected, sendFileConflictBlocked := verifySendFilePreview(ctx, session, sendFilePath) configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx) configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx) @@ -267,6 +273,8 @@ func main() { "production_send_enabled": productionSendEnabled, "send_file_preview_tool_present": sendFilePreviewOK, "send_file_production_enabled": sendFileProductionEnabled, + "send_file_duplicate_detected": sendFileDuplicateDetected, + "send_file_conflict_blocked": sendFileConflictBlocked, "msglib_configured": false, } encoded, _ := json.Marshal(result) @@ -586,9 +594,9 @@ func verifySendMessagePreview(ctx context.Context, session *mcp.ClientSession) ( return true, false } -func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, filePath string) (bool, bool) { +func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, filePath string) (bool, bool, bool, bool) { fileHash := sha256FileHexForHarness(filePath) - callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{ + previewArgs := map[string]any{ "target_type": "direct", "target_id": "sender@imopenfire1-lanzhou", "file_path": filePath, @@ -596,7 +604,8 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file "idempotency_key": "verify-file-preview-idempotency-key", "execution_mode": "preview", "preview": true, - }}) + } + callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: previewArgs}) if err != nil { fail("tools/call isphere_send_file preview", err) } @@ -619,6 +628,62 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file fail("tools/call isphere_send_file preview", fmt.Errorf("unexpected send-file side effects: %s", encoded)) } + duplicateResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: previewArgs}) + if err != nil { + fail("tools/call isphere_send_file duplicate", err) + } + if duplicateResult.IsError { + fail("tools/call isphere_send_file duplicate", fmt.Errorf("tool returned isError=true: %#v", duplicateResult.Content)) + } + var duplicatePayload map[string]any + duplicateEncoded, _ := json.Marshal(duplicateResult.StructuredContent) + if err := json.Unmarshal(duplicateEncoded, &duplicatePayload); err != nil { + fail("tools/call isphere_send_file duplicate", fmt.Errorf("decode structuredContent %s: %w", duplicateEncoded, err)) + } + duplicateIdempotency, ok := duplicatePayload["idempotency"].(map[string]any) + if !ok || duplicatePayload["ok"] != true || duplicatePayload["send_status"] != "planned" || duplicatePayload["real_send_attempted"] != false || duplicateIdempotency["duplicate_detected"] != true || duplicateIdempotency["conflict_detected"] != false { + fail("tools/call isphere_send_file duplicate", fmt.Errorf("duplicate idempotency mismatch: %s", duplicateEncoded)) + } + + conflictPath := filepath.Join(filepath.Dir(filePath), "redacted-conflict.txt") + if err := os.WriteFile(conflictPath, []byte("world"), 0o600); err != nil { + fail("tools/call isphere_send_file conflict_fixture", err) + } + conflictKey := "verify-file-conflict-idempotency-key" + if _, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{ + "target_type": "direct", + "target_id": "sender@imopenfire1-lanzhou", + "file_path": filePath, + "file_sha256": fileHash, + "idempotency_key": conflictKey, + "execution_mode": "preview", + }}); err != nil { + fail("tools/call isphere_send_file conflict_first", err) + } + conflictResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{ + "target_type": "direct", + "target_id": "sender@imopenfire1-lanzhou", + "file_path": conflictPath, + "file_sha256": sha256FileHexForHarness(conflictPath), + "idempotency_key": conflictKey, + "execution_mode": "preview", + }}) + if err != nil { + fail("tools/call isphere_send_file conflict", err) + } + if conflictResult.IsError { + fail("tools/call isphere_send_file conflict", fmt.Errorf("tool returned isError=true: %#v", conflictResult.Content)) + } + var conflictPayload map[string]any + conflictEncoded, _ := json.Marshal(conflictResult.StructuredContent) + if err := json.Unmarshal(conflictEncoded, &conflictPayload); err != nil { + fail("tools/call isphere_send_file conflict", fmt.Errorf("decode structuredContent %s: %w", conflictEncoded, err)) + } + conflictIdempotency, ok := conflictPayload["idempotency"].(map[string]any) + if !ok || conflictPayload["ok"] != false || conflictPayload["send_status"] != "blocked" || conflictPayload["real_send_attempted"] != false || conflictIdempotency["duplicate_detected"] != false || conflictIdempotency["conflict_detected"] != true { + fail("tools/call isphere_send_file conflict", fmt.Errorf("conflict idempotency mismatch: %s", conflictEncoded)) + } + productionResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{ "target_type": "direct", "target_id": "sender@imopenfire1-lanzhou", @@ -645,7 +710,7 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file if !ok || productionSideEffects["sent_file"] != false || productionSideEffects["uploaded_file"] != false || productionSideEffects["captured_network"] != false { fail("tools/call isphere_send_file production_blocked", fmt.Errorf("unexpected production file side effects: %s", productionEncoded)) } - return true, false + return true, false, true, true } func verifySearchContacts(ctx context.Context, session *mcp.ClientSession, query string, step string, wantCount int) int { @@ -856,6 +921,8 @@ func fail(step string, err error) { Assert-True ($script:harnessJson.production_send_enabled -eq $false) "SDK harness production_send_enabled mismatch: $($script:harnessJson.production_send_enabled)" Assert-True ($script:harnessJson.send_file_preview_tool_present -eq $true) "SDK harness send_file_preview_tool_present mismatch: $($script:harnessJson.send_file_preview_tool_present)" Assert-True ($script:harnessJson.send_file_production_enabled -eq $false) "SDK harness send_file_production_enabled mismatch: $($script:harnessJson.send_file_production_enabled)" + Assert-True ($script:harnessJson.send_file_duplicate_detected -eq $true) "SDK harness send_file_duplicate_detected mismatch: $($script:harnessJson.send_file_duplicate_detected)" + Assert-True ($script:harnessJson.send_file_conflict_blocked -eq $true) "SDK harness send_file_conflict_blocked mismatch: $($script:harnessJson.send_file_conflict_blocked)" [ordered]@{ ok = $true @@ -880,6 +947,8 @@ func fail(step string, err error) { production_send_enabled = $script:harnessJson.production_send_enabled send_file_preview_tool_present = $script:harnessJson.send_file_preview_tool_present send_file_production_enabled = $script:harnessJson.send_file_production_enabled + send_file_duplicate_detected = $script:harnessJson.send_file_duplicate_detected + send_file_conflict_blocked = $script:harnessJson.send_file_conflict_blocked packet_log_file_configured = $script:harnessJson.packet_log_file_configured packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured python_runtime_required = $false