Files
isphere-ai-bridge/internal/tools/isphere_send_message.go
2026-07-10 14:04:33 +08:00

304 lines
10 KiB
Go

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