feat: add send message preview contract
This commit is contained in:
303
internal/tools/isphere_send_message.go
Normal file
303
internal/tools/isphere_send_message.go
Normal file
@@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
180
internal/tools/isphere_send_message_test.go
Normal file
180
internal/tools/isphere_send_message_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
content := "hello preview"
|
||||
contentHash := sha256HexForSendMessageTest(content)
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "idem-preview-1",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
ConnectorStage string `json:"connector_stage"`
|
||||
Target map[string]any `json:"target"`
|
||||
Content map[string]any `json:"content"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
Audit map[string]any `json:"audit"`
|
||||
}
|
||||
payload, err := json.Marshal(callResult.StructuredContent)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal structured content: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode structured content %s: %v", payload, err)
|
||||
}
|
||||
if !decoded.OK || decoded.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
|
||||
t.Fatalf("unexpected preview status: %s", payload)
|
||||
}
|
||||
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
|
||||
t.Fatalf("unexpected connector fields: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
|
||||
}
|
||||
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
|
||||
t.Fatalf("preview response leaked raw content body: %s", payload)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 {
|
||||
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||
}
|
||||
event := audit.events[0]
|
||||
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
|
||||
t.Fatalf("unexpected audit event: %+v", event)
|
||||
}
|
||||
if event.ContentText != "" || event.IdempotencyKey != "" {
|
||||
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
contentHash := sha256HexForSendMessageTest("blocked send")
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "group",
|
||||
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
||||
"content_text": "blocked send",
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "idem-production-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
Target map[string]any `json:"target"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
Audit map[string]any `json:"audit"`
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode structured content %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
|
||||
t.Fatalf("production was not clearly blocked: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "blocked" {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
|
||||
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{})
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": "hash mismatch",
|
||||
"content_sha256": sha256HexForSendMessageTest("different body"),
|
||||
"idempotency_key": "idem-hash-mismatch",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err == nil && !callResult.IsError {
|
||||
t.Fatalf("hash mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSendMessageAuditSink struct {
|
||||
events []SendMessageAuditEvent
|
||||
}
|
||||
|
||||
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
||||
f.events = append(f.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256HexForSendMessageTest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user