feat: harden send file idempotency audit
This commit is contained in:
@@ -1,22 +1,27 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolNameSendFile = "isphere_send_file"
|
||||
EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR"
|
||||
ToolNameSendFile = "isphere_send_file"
|
||||
EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR"
|
||||
EnvSendFileAuditPath = "ISPHERE_SEND_FILE_AUDIT_PATH"
|
||||
EnvSendFileIdempotencyPath = "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"
|
||||
|
||||
sendFileConnector = "implatform-file-transfer"
|
||||
sendFileConnectorMode = "preview-only"
|
||||
@@ -46,27 +51,226 @@ type normalizedSendFileArgs struct {
|
||||
ExecutionMode string
|
||||
}
|
||||
|
||||
type SendFileAuditSink interface {
|
||||
AppendSendFileAudit(ctx context.Context, event SendFileAuditEvent) error
|
||||
}
|
||||
|
||||
type SendFileIdempotencyStore interface {
|
||||
ReserveSendFileIdempotency(ctx context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error)
|
||||
}
|
||||
|
||||
type SendFileAuditEvent struct {
|
||||
Tool string `json:"tool"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetRef string `json:"target_ref"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
ConnectorMode string `json:"connector_mode"`
|
||||
ConnectorStage string `json:"connector_stage"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes"`
|
||||
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
||||
SendStatus string `json:"send_status"`
|
||||
Result string `json:"result"`
|
||||
BlockedReason string `json:"blocked_reason,omitempty"`
|
||||
AuditRef string `json:"audit_ref"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
RealSendAttempted bool `json:"real_send_attempted"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
FilePath string `json:"-"`
|
||||
IdempotencyKey string `json:"-"`
|
||||
}
|
||||
|
||||
type SendFileIdempotencyRecord struct {
|
||||
Tool string `json:"tool"`
|
||||
TargetRef string `json:"target_ref"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
ConnectorStage string `json:"connector_stage"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
IdempotencyKeySHA256 string `json:"idempotency_key_sha256"`
|
||||
AuditRef string `json:"audit_ref"`
|
||||
StartedAt string `json:"started_at"`
|
||||
}
|
||||
|
||||
type SendFileIdempotencyDecision struct {
|
||||
Duplicate bool
|
||||
Conflict bool
|
||||
FirstAuditRef string
|
||||
FirstStartedAt string
|
||||
}
|
||||
|
||||
type fileSendFileAuditSink struct {
|
||||
path string
|
||||
}
|
||||
|
||||
type fileSendFileIdempotencyStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
var sendFileIdempotencyFileMu sync.Mutex
|
||||
|
||||
func RegisterISphereSendFileTool(server *mcp.Server) {
|
||||
RegisterISphereSendFileToolWithState(server, nil, nil)
|
||||
}
|
||||
|
||||
func RegisterISphereSendFileToolWithState(server *mcp.Server, auditSink SendFileAuditSink, idempotencyStore SendFileIdempotencyStore) {
|
||||
if auditSink == nil {
|
||||
auditSink = defaultSendFileAuditSink()
|
||||
}
|
||||
if idempotencyStore == nil {
|
||||
idempotencyStore = defaultSendFileIdempotencyStore()
|
||||
}
|
||||
|
||||
mcp.AddTool[SendFileArgs, map[string]any](server, &mcp.Tool{
|
||||
Name: ToolNameSendFile,
|
||||
Description: "Preview iSphere file sends with target/file hash/idempotency metadata; production file upload is blocked until sandbox evidence passes.",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, input SendFileArgs) (*mcp.CallToolResult, map[string]any, error) {
|
||||
_ = ctx
|
||||
started := time.Now().UTC()
|
||||
normalized, blockedReason, err := normalizeSendFileArgs(input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
var event SendFileAuditEvent
|
||||
if blockedReason != "" {
|
||||
return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason), nil
|
||||
response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason)
|
||||
} else if normalized.ExecutionMode == sendFileProductionMode {
|
||||
response, event = sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes")
|
||||
} else {
|
||||
response, event = sendFileResponse(normalized, started, time.Now().UTC(), true, "")
|
||||
}
|
||||
if normalized.ExecutionMode == sendFileProductionMode {
|
||||
return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes"), nil
|
||||
|
||||
if normalized.FileSHA256 != "" {
|
||||
decision, err := idempotencyStore.ReserveSendFileIdempotency(ctx, sendFileIdempotencyRecordFromAuditEvent(event))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendFile, err)
|
||||
}
|
||||
applySendFileIdempotencyDecision(response, &event, decision)
|
||||
}
|
||||
return nil, sendFileResponse(normalized, started, time.Now().UTC(), true, ""), nil
|
||||
if err := auditSink.AppendSendFileAudit(ctx, event); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendFile, err)
|
||||
}
|
||||
return nil, response, nil
|
||||
})
|
||||
}
|
||||
|
||||
func defaultSendFileAuditSink() SendFileAuditSink {
|
||||
if path := strings.TrimSpace(os.Getenv(EnvSendFileAuditPath)); path != "" {
|
||||
return fileSendFileAuditSink{path: path}
|
||||
}
|
||||
return fileSendFileAuditSink{path: filepath.Join("runs", "send-audit", "send-file-preview.jsonl")}
|
||||
}
|
||||
|
||||
func defaultSendFileIdempotencyStore() SendFileIdempotencyStore {
|
||||
if path := strings.TrimSpace(os.Getenv(EnvSendFileIdempotencyPath)); path != "" {
|
||||
return fileSendFileIdempotencyStore{path: path}
|
||||
}
|
||||
return fileSendFileIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-file-idempotency.jsonl")}
|
||||
}
|
||||
|
||||
func (s fileSendFileAuditSink) AppendSendFileAudit(_ context.Context, event SendFileAuditEvent) error {
|
||||
if strings.TrimSpace(s.path) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(payload, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s fileSendFileIdempotencyStore) ReserveSendFileIdempotency(_ context.Context, record SendFileIdempotencyRecord) (SendFileIdempotencyDecision, error) {
|
||||
if strings.TrimSpace(s.path) == "" {
|
||||
return SendFileIdempotencyDecision{}, nil
|
||||
}
|
||||
if record.IdempotencyKeySHA256 == "" {
|
||||
return SendFileIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required")
|
||||
}
|
||||
sendFileIdempotencyFileMu.Lock()
|
||||
defer sendFileIdempotencyFileMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return SendFileIdempotencyDecision{}, err
|
||||
}
|
||||
existing, err := readSendFileIdempotencyRecords(s.path)
|
||||
if err != nil {
|
||||
return SendFileIdempotencyDecision{}, err
|
||||
}
|
||||
for _, candidate := range existing {
|
||||
if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
|
||||
continue
|
||||
}
|
||||
decision := SendFileIdempotencyDecision{
|
||||
FirstAuditRef: candidate.AuditRef,
|
||||
FirstStartedAt: candidate.StartedAt,
|
||||
}
|
||||
if candidate.TargetRef == record.TargetRef && candidate.FileSHA256 == record.FileSHA256 {
|
||||
decision.Duplicate = true
|
||||
return decision, nil
|
||||
}
|
||||
decision.Conflict = true
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return SendFileIdempotencyDecision{}, err
|
||||
}
|
||||
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return SendFileIdempotencyDecision{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(payload, '\n')); err != nil {
|
||||
return SendFileIdempotencyDecision{}, err
|
||||
}
|
||||
return SendFileIdempotencyDecision{}, nil
|
||||
}
|
||||
|
||||
func readSendFileIdempotencyRecords(path string) ([]SendFileIdempotencyRecord, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var records []SendFileIdempotencyRecord
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var record SendFileIdempotencyRecord
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
return nil, fmt.Errorf("decode send-file idempotency record: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func normalizeSendFileArgs(input SendFileArgs) (normalizedSendFileArgs, string, error) {
|
||||
targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType)
|
||||
if err != nil {
|
||||
@@ -194,7 +398,7 @@ func sha256FileHex(path string) (string, error) {
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) map[string]any {
|
||||
func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) (map[string]any, SendFileAuditEvent) {
|
||||
sideEffects := sendMessageNoSideEffects()
|
||||
status := "planned"
|
||||
stage := sendFilePreviewStage
|
||||
@@ -207,7 +411,30 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
||||
blocked = blockedReason
|
||||
}
|
||||
auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256)
|
||||
return map[string]any{
|
||||
event := SendFileAuditEvent{
|
||||
Tool: ToolNameSendFile,
|
||||
TargetType: input.TargetType,
|
||||
TargetID: input.TargetID,
|
||||
TargetRef: input.TargetRef,
|
||||
ExecutionMode: input.ExecutionMode,
|
||||
Connector: sendFileConnector,
|
||||
ConnectorMode: sendFileConnectorMode,
|
||||
ConnectorStage: stage,
|
||||
FileSHA256: input.FileSHA256,
|
||||
FileSizeBytes: input.FileSizeBytes,
|
||||
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
|
||||
SendStatus: status,
|
||||
Result: status,
|
||||
AuditRef: auditRef,
|
||||
StartedAt: started.Format(time.RFC3339Nano),
|
||||
FinishedAt: finished.Format(time.RFC3339Nano),
|
||||
RealSendAttempted: false,
|
||||
SideEffects: sideEffects,
|
||||
}
|
||||
if blockedReason != "" {
|
||||
event.BlockedReason = blockedReason
|
||||
}
|
||||
response := map[string]any{
|
||||
"ok": ok,
|
||||
"send_status": status,
|
||||
"blocked_reason": blocked,
|
||||
@@ -234,6 +461,10 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
||||
},
|
||||
"idempotency": map[string]any{
|
||||
"idempotency_key_sha256": input.IdempotencyKeySHA256,
|
||||
"duplicate_detected": false,
|
||||
"conflict_detected": false,
|
||||
"first_audit_ref": nil,
|
||||
"first_started_at": nil,
|
||||
},
|
||||
"side_effect_flags": sideEffects,
|
||||
"side_effects": sideEffects,
|
||||
@@ -254,4 +485,58 @@ func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished
|
||||
"real_send_attempted": false,
|
||||
},
|
||||
}
|
||||
return response, event
|
||||
}
|
||||
|
||||
func sendFileIdempotencyRecordFromAuditEvent(event SendFileAuditEvent) SendFileIdempotencyRecord {
|
||||
return SendFileIdempotencyRecord{
|
||||
Tool: event.Tool,
|
||||
TargetRef: event.TargetRef,
|
||||
ExecutionMode: event.ExecutionMode,
|
||||
Connector: event.Connector,
|
||||
ConnectorStage: event.ConnectorStage,
|
||||
FileSHA256: event.FileSHA256,
|
||||
IdempotencyKeySHA256: event.IdempotencyKeySHA256,
|
||||
AuditRef: event.AuditRef,
|
||||
StartedAt: event.StartedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func applySendFileIdempotencyDecision(response map[string]any, event *SendFileAuditEvent, decision SendFileIdempotencyDecision) {
|
||||
idempotency, _ := response["idempotency"].(map[string]any)
|
||||
if idempotency == nil {
|
||||
idempotency = map[string]any{}
|
||||
response["idempotency"] = idempotency
|
||||
}
|
||||
idempotency["duplicate_detected"] = decision.Duplicate
|
||||
idempotency["conflict_detected"] = decision.Conflict
|
||||
if decision.FirstAuditRef != "" {
|
||||
idempotency["first_audit_ref"] = decision.FirstAuditRef
|
||||
}
|
||||
if decision.FirstStartedAt != "" {
|
||||
idempotency["first_started_at"] = decision.FirstStartedAt
|
||||
}
|
||||
if !decision.Conflict {
|
||||
return
|
||||
}
|
||||
|
||||
blockedReason := "idempotency key was already used for a different target or file"
|
||||
response["ok"] = false
|
||||
response["send_status"] = "blocked"
|
||||
response["blocked_reason"] = blockedReason
|
||||
response["connector_stage"] = sendFileBlockedStage
|
||||
|
||||
audit, _ := response["audit"].(map[string]any)
|
||||
if audit != nil {
|
||||
audit["result"] = "blocked"
|
||||
audit["connector_stage"] = sendFileBlockedStage
|
||||
audit["blocked_reason"] = blockedReason
|
||||
}
|
||||
|
||||
if event != nil {
|
||||
event.ConnectorStage = sendFileBlockedStage
|
||||
event.SendStatus = "blocked"
|
||||
event.Result = "blocked"
|
||||
event.BlockedReason = blockedReason
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,161 @@ func TestISphereSendFileRejectsPathOutsideAllowedDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileDuplicateIdempotencyDetected(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
filePath := writeSendFileFixture(t, allowedDir, "duplicate.txt", "duplicate")
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
|
||||
t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl"))
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
args := map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": sha256HexForSendMessageTest("duplicate"),
|
||||
"idempotency_key": "file-duplicate-idem-1",
|
||||
"execution_mode": "preview",
|
||||
"preview": true,
|
||||
}
|
||||
if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args}); err != nil {
|
||||
t.Fatalf("first send-file preview: %v", err)
|
||||
}
|
||||
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: args})
|
||||
if err != nil {
|
||||
t.Fatalf("second send-file preview: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
RealSendAttempted bool `json:"real_send_attempted"`
|
||||
Idempotency map[string]any `json:"idempotency"`
|
||||
SideEffectFlags map[string]any `json:"side_effect_flags"`
|
||||
}
|
||||
payload, _ := json.Marshal(second.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode duplicate payload %s: %v", payload, err)
|
||||
}
|
||||
if !decoded.OK || decoded.SendStatus != "planned" || decoded.RealSendAttempted {
|
||||
t.Fatalf("duplicate should remain planned preview/no-send: %s", payload)
|
||||
}
|
||||
if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false {
|
||||
t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency)
|
||||
}
|
||||
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false {
|
||||
t.Fatalf("duplicate should have no file side effects: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileIdempotencyConflictBlocked(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
firstPath := writeSendFileFixture(t, allowedDir, "first.txt", "first")
|
||||
secondPath := writeSendFileFixture(t, allowedDir, "second.txt", "second")
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
|
||||
t.Setenv(EnvSendFileAuditPath, filepath.Join(t.TempDir(), "send-file-audit.jsonl"))
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
sharedKey := "file-conflict-idem-1"
|
||||
_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": firstPath,
|
||||
"file_sha256": sha256HexForSendMessageTest("first"),
|
||||
"idempotency_key": sharedKey,
|
||||
"execution_mode": "preview",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("first send-file preview: %v", err)
|
||||
}
|
||||
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": secondPath,
|
||||
"file_sha256": sha256HexForSendMessageTest("second"),
|
||||
"idempotency_key": sharedKey,
|
||||
"execution_mode": "preview",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("conflict send-file preview: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
RealSendAttempted bool `json:"real_send_attempted"`
|
||||
Idempotency map[string]any `json:"idempotency"`
|
||||
SideEffectFlags map[string]any `json:"side_effect_flags"`
|
||||
}
|
||||
payload, _ := json.Marshal(second.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode conflict payload %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" || decoded.RealSendAttempted {
|
||||
t.Fatalf("conflict should be blocked/no-send: %s", payload)
|
||||
}
|
||||
if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true {
|
||||
t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency)
|
||||
}
|
||||
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["typed_text"] != false {
|
||||
t.Fatalf("conflict should have no side effects: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileAuditRedactionIsStable(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
filePath := writeSendFileFixture(t, allowedDir, "secret.txt", "secret file bytes")
|
||||
auditPath := filepath.Join(t.TempDir(), "send-file-audit.jsonl")
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
t.Setenv(EnvSendFileAuditPath, auditPath)
|
||||
t.Setenv(EnvSendFileIdempotencyPath, filepath.Join(t.TempDir(), "send-file-idempotency.jsonl"))
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
fileHash := sha256HexForSendMessageTest("secret file bytes")
|
||||
idempotencyKey := "raw-file-idempotency-key-must-not-be-stored"
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendFile, Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": fileHash,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"execution_mode": "preview",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("send-file preview: %v", err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("send-file preview should not be tool error: %+v", callResult)
|
||||
}
|
||||
payload, err := os.ReadFile(auditPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read send-file audit: %v", err)
|
||||
}
|
||||
audit := string(payload)
|
||||
for _, forbidden := range []string{filePath, idempotencyKey, "secret file bytes"} {
|
||||
if strings.Contains(audit, forbidden) {
|
||||
t.Fatalf("send-file audit leaked forbidden value %q: %s", forbidden, audit)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(audit, fileHash) {
|
||||
t.Fatalf("send-file audit missing file hash: %s", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
filePath := filepath.Join(allowedDir, "blocked.txt")
|
||||
@@ -185,3 +340,12 @@ func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) {
|
||||
t.Fatalf("production blocked should have no side effects: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSendFileFixture(t *testing.T, dir string, name string, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write send-file fixture %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user