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" EnvSendFileAuditPath = "ISPHERE_SEND_FILE_AUDIT_PATH" EnvSendFileIdempotencyPath = "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH" sendFileConnector = "implatform-file-transfer" sendFileConnectorMode = "preview-only" sendFilePreviewStage = "preview" sendFileBlockedStage = "blocked" sendFileProductionMode = "production" ) type SendFileArgs struct { TargetType string `json:"target_type" jsonschema:"target type; direct/contact or group/groupchat"` TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"` FilePath string `json:"file_path" jsonschema:"local file path under ISPHERE_SEND_FILE_ALLOWED_DIR to hash and preview"` FileSHA256 string `json:"file_sha256,omitempty" jsonschema:"optional lowercase SHA256 hex of file contents; checked when supplied"` IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; returned only as SHA256"` ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview/dry_run or production; production is blocked until file-send evidence passes"` Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility; current implementation is preview-only"` } type normalizedSendFileArgs struct { TargetType string TargetID string TargetRef string FileName string FileSHA256 string FileSizeBytes int64 IdempotencyKeySHA256 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) { 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) { 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 != "" { 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.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) } 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 { return normalizedSendFileArgs{}, "", err } targetID := strings.TrimSpace(input.TargetID) if targetID == "" { return normalizedSendFileArgs{}, "", fmt.Errorf("%s target_id is required", ToolNameSendFile) } idempotencyKey := strings.TrimSpace(input.IdempotencyKey) if idempotencyKey == "" { return normalizedSendFileArgs{}, "", fmt.Errorf("%s idempotency_key is required", ToolNameSendFile) } mode, err := normalizeSendFileExecutionMode(input.ExecutionMode) if err != nil { return normalizedSendFileArgs{}, "", err } normalized := normalizedSendFileArgs{ TargetType: targetType, TargetID: targetID, TargetRef: targetRefPrefix + ":" + targetID, FileName: filepath.Base(strings.TrimSpace(input.FilePath)), IdempotencyKeySHA256: sha256Hex(idempotencyKey), ExecutionMode: mode, } fileSHA256, fileSize, blockedReason, err := inspectSendFilePath(input.FilePath, input.FileSHA256) if err != nil { return normalizedSendFileArgs{}, "", err } normalized.FileSHA256 = fileSHA256 normalized.FileSizeBytes = fileSize return normalized, blockedReason, nil } func normalizeSendFileTargetType(value string) (string, string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "direct", "contact": return "direct", "contact", nil case "group", "groupchat": return "group", "group", nil default: return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendFile, value) } } func normalizeSendFileExecutionMode(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "preview", "dry_run", "dry-run": return "preview", nil case sendFileProductionMode: return sendFileProductionMode, nil default: return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendFile, value) } } func inspectSendFilePath(filePath string, providedSHA256 string) (string, int64, string, error) { trimmedPath := strings.TrimSpace(filePath) if trimmedPath == "" { return "", 0, "", fmt.Errorf("%s file_path is required", ToolNameSendFile) } allowedDir := strings.TrimSpace(os.Getenv(EnvSendFileAllowedDir)) if allowedDir == "" { return "", 0, fmt.Sprintf("%s is required before previewing local file paths", EnvSendFileAllowedDir), nil } allowedAbs, err := filepath.Abs(allowedDir) if err != nil { return "", 0, "", fmt.Errorf("%s allowed dir: %w", ToolNameSendFile, err) } fileAbs, err := filepath.Abs(trimmedPath) if err != nil { return "", 0, "", fmt.Errorf("%s file_path: %w", ToolNameSendFile, err) } if !sendFilePathInsideDir(fileAbs, allowedAbs) { return "", 0, fmt.Sprintf("file_path must stay inside %s", EnvSendFileAllowedDir), nil } info, err := os.Stat(fileAbs) if err != nil { if os.IsNotExist(err) { return "", 0, "file_path does not exist under allowed directory", nil } return "", 0, "", fmt.Errorf("%s stat file_path: %w", ToolNameSendFile, err) } if info.IsDir() { return "", 0, "file_path points to a directory; choose one file", nil } fileHash, err := sha256FileHex(fileAbs) if err != nil { return "", 0, "", fmt.Errorf("%s hash file_path: %w", ToolNameSendFile, err) } normalizedProvided := strings.ToLower(strings.TrimSpace(providedSHA256)) if normalizedProvided != "" { if len(normalizedProvided) != 64 || !isLowerHex(normalizedProvided) { return "", 0, "", fmt.Errorf("%s file_sha256 must be a lowercase SHA256 hex value", ToolNameSendFile) } if normalizedProvided != fileHash { return "", 0, "", fmt.Errorf("%s file_sha256 does not match file contents", ToolNameSendFile) } } return fileHash, info.Size(), "", nil } func sendFilePathInsideDir(fileAbs string, allowedAbs string) bool { rel, err := filepath.Rel(filepath.Clean(allowedAbs), filepath.Clean(fileAbs)) if err != nil { return false } if rel == "." { return true } return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel) } func sha256FileHex(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) (map[string]any, SendFileAuditEvent) { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendFilePreviewStage ok := true blocked := any(nil) if !planned { status = "blocked" stage = sendFileBlockedStage ok = false blocked = blockedReason } auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256) 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, "execution_mode": input.ExecutionMode, "connector": sendFileConnector, "connector_mode": sendFileConnectorMode, "connector_stage": stage, "production_send_enabled": false, "real_send_attempted": false, "target_ref": input.TargetRef, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "target": map[string]any{ "target_type": input.TargetType, "target_id": input.TargetID, "target_ref": input.TargetRef, }, "file": map[string]any{ "file_name": input.FileName, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "allowed_dir_enforced": true, "file_content_returned": false, }, "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, "audit": map[string]any{ "tool": ToolNameSendFile, "source": sendFileConnector, "execution_mode": input.ExecutionMode, "connector": sendFileConnector, "connector_mode": sendFileConnectorMode, "connector_stage": stage, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "target_ref": input.TargetRef, "audit_ref": auditRef, "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": status, "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 } }