feat: harden send file idempotency audit
This commit is contained in:
@@ -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`.
|
- 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.
|
- 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.
|
- 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.
|
- Last completed round: R8 send-file idempotency and audit hardening.
|
||||||
- Next active 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:
|
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.
|
- `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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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"
|
$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:
|
R6g adds a local diagnostic for replaying the redacted audit/idempotency files without opening iSphere:
|
||||||
|
|
||||||
|
|||||||
@@ -594,19 +594,19 @@ git push gitea main
|
|||||||
- Duplicate same target/file/idempotency returns `duplicate_detected=true` with no second send.
|
- 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`.
|
- 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`.
|
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`.
|
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.
|
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
|
```powershell
|
||||||
git diff --check
|
git diff --check
|
||||||
@@ -621,6 +621,15 @@ git push gitea main
|
|||||||
|
|
||||||
**Acceptance gate:** File upload remains blocked, but duplicate/conflict behavior is deterministic.
|
**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
|
### R9: Send-file upload evidence package
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -17,6 +20,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
ToolNameSendFile = "isphere_send_file"
|
ToolNameSendFile = "isphere_send_file"
|
||||||
EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR"
|
EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR"
|
||||||
|
EnvSendFileAuditPath = "ISPHERE_SEND_FILE_AUDIT_PATH"
|
||||||
|
EnvSendFileIdempotencyPath = "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"
|
||||||
|
|
||||||
sendFileConnector = "implatform-file-transfer"
|
sendFileConnector = "implatform-file-transfer"
|
||||||
sendFileConnectorMode = "preview-only"
|
sendFileConnectorMode = "preview-only"
|
||||||
@@ -46,27 +51,226 @@ type normalizedSendFileArgs struct {
|
|||||||
ExecutionMode string
|
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) {
|
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{
|
mcp.AddTool[SendFileArgs, map[string]any](server, &mcp.Tool{
|
||||||
Name: ToolNameSendFile,
|
Name: ToolNameSendFile,
|
||||||
Description: "Preview iSphere file sends with target/file hash/idempotency metadata; production file upload is blocked until sandbox evidence passes.",
|
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) {
|
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendFileArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||||
_ = ctx
|
|
||||||
started := time.Now().UTC()
|
started := time.Now().UTC()
|
||||||
normalized, blockedReason, err := normalizeSendFileArgs(input)
|
normalized, blockedReason, err := normalizeSendFileArgs(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var response map[string]any
|
||||||
|
var event SendFileAuditEvent
|
||||||
if blockedReason != "" {
|
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)
|
||||||
}
|
}
|
||||||
return nil, sendFileResponse(normalized, started, time.Now().UTC(), true, ""), nil
|
applySendFileIdempotencyDecision(response, &event, decision)
|
||||||
|
}
|
||||||
|
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) {
|
func normalizeSendFileArgs(input SendFileArgs) (normalizedSendFileArgs, string, error) {
|
||||||
targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType)
|
targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,7 +398,7 @@ func sha256FileHex(path string) (string, error) {
|
|||||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
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()
|
sideEffects := sendMessageNoSideEffects()
|
||||||
status := "planned"
|
status := "planned"
|
||||||
stage := sendFilePreviewStage
|
stage := sendFilePreviewStage
|
||||||
@@ -207,7 +411,30 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
|||||||
blocked = blockedReason
|
blocked = blockedReason
|
||||||
}
|
}
|
||||||
auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256)
|
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,
|
"ok": ok,
|
||||||
"send_status": status,
|
"send_status": status,
|
||||||
"blocked_reason": blocked,
|
"blocked_reason": blocked,
|
||||||
@@ -234,6 +461,10 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
|||||||
},
|
},
|
||||||
"idempotency": map[string]any{
|
"idempotency": map[string]any{
|
||||||
"idempotency_key_sha256": input.IdempotencyKeySHA256,
|
"idempotency_key_sha256": input.IdempotencyKeySHA256,
|
||||||
|
"duplicate_detected": false,
|
||||||
|
"conflict_detected": false,
|
||||||
|
"first_audit_ref": nil,
|
||||||
|
"first_started_at": nil,
|
||||||
},
|
},
|
||||||
"side_effect_flags": sideEffects,
|
"side_effect_flags": sideEffects,
|
||||||
"side_effects": sideEffects,
|
"side_effects": sideEffects,
|
||||||
@@ -254,4 +485,58 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
|||||||
"real_send_attempted": false,
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) {
|
||||||
allowedDir := t.TempDir()
|
allowedDir := t.TempDir()
|
||||||
filePath := filepath.Join(allowedDir, "blocked.txt")
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err := os.Unsetenv(key); err != nil {
|
||||||
fail("server/env", err)
|
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 {
|
if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-idempotency.jsonl")); err != nil {
|
||||||
fail("server/send_state", err)
|
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-*")
|
sendFileDir, err := os.MkdirTemp("", "isphere-send-file-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("server/send_file_state", err)
|
fail("server/send_file_state", err)
|
||||||
@@ -235,7 +241,7 @@ func main() {
|
|||||||
defaultGroupCount := verifySearchGroups(ctx, session, "project", "tools/call isphere_search_groups 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, "", "")
|
defaultFileCount := verifyReceiveFiles(ctx, session, "report", "tools/call isphere_receive_files default", 0, "", "")
|
||||||
sendPreviewOK, productionSendEnabled := verifySendMessagePreview(ctx, session)
|
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)
|
configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx)
|
||||||
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
|
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
|
||||||
|
|
||||||
@@ -267,6 +273,8 @@ func main() {
|
|||||||
"production_send_enabled": productionSendEnabled,
|
"production_send_enabled": productionSendEnabled,
|
||||||
"send_file_preview_tool_present": sendFilePreviewOK,
|
"send_file_preview_tool_present": sendFilePreviewOK,
|
||||||
"send_file_production_enabled": sendFileProductionEnabled,
|
"send_file_production_enabled": sendFileProductionEnabled,
|
||||||
|
"send_file_duplicate_detected": sendFileDuplicateDetected,
|
||||||
|
"send_file_conflict_blocked": sendFileConflictBlocked,
|
||||||
"msglib_configured": false,
|
"msglib_configured": false,
|
||||||
}
|
}
|
||||||
encoded, _ := json.Marshal(result)
|
encoded, _ := json.Marshal(result)
|
||||||
@@ -586,9 +594,9 @@ func verifySendMessagePreview(ctx context.Context, session *mcp.ClientSession) (
|
|||||||
return true, false
|
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)
|
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_type": "direct",
|
||||||
"target_id": "sender@imopenfire1-lanzhou",
|
"target_id": "sender@imopenfire1-lanzhou",
|
||||||
"file_path": filePath,
|
"file_path": filePath,
|
||||||
@@ -596,7 +604,8 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file
|
|||||||
"idempotency_key": "verify-file-preview-idempotency-key",
|
"idempotency_key": "verify-file-preview-idempotency-key",
|
||||||
"execution_mode": "preview",
|
"execution_mode": "preview",
|
||||||
"preview": true,
|
"preview": true,
|
||||||
}})
|
}
|
||||||
|
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: previewArgs})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("tools/call isphere_send_file preview", err)
|
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))
|
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{
|
productionResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{
|
||||||
"target_type": "direct",
|
"target_type": "direct",
|
||||||
"target_id": "sender@imopenfire1-lanzhou",
|
"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 {
|
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))
|
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 {
|
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.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_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_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]@{
|
[ordered]@{
|
||||||
ok = $true
|
ok = $true
|
||||||
@@ -880,6 +947,8 @@ func fail(step string, err error) {
|
|||||||
production_send_enabled = $script:harnessJson.production_send_enabled
|
production_send_enabled = $script:harnessJson.production_send_enabled
|
||||||
send_file_preview_tool_present = $script:harnessJson.send_file_preview_tool_present
|
send_file_preview_tool_present = $script:harnessJson.send_file_preview_tool_present
|
||||||
send_file_production_enabled = $script:harnessJson.send_file_production_enabled
|
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_file_configured = $script:harnessJson.packet_log_file_configured
|
||||||
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
|
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
|
||||||
python_runtime_required = $false
|
python_runtime_required = $false
|
||||||
|
|||||||
Reference in New Issue
Block a user