feat: harden send file idempotency audit
This commit is contained in:
@@ -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