feat: harden send file idempotency audit
This commit is contained in:
@@ -240,8 +240,8 @@ Continuous execution plan R6f-R14 is approved and execution has started:
|
||||
- Plan file: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`.
|
||||
- Scope: 15 ordered rounds covering send-message connector/gate hardening, send-file preview/idempotency/package, receive-file download preview, receive-message reconciliation, end-to-end business smoke, and release-candidate report.
|
||||
- Execution rule after approval: one round at a time, status-card update, verification, commit, push, then continue automatically until an evidence-only blocker requires user action.
|
||||
- Last completed round: R7 send-file preview tool.
|
||||
- Next active round: R8 send-file idempotency and audit hardening.
|
||||
- Last completed round: R8 send-file idempotency and audit hardening.
|
||||
- Next active round: R9 send-file upload evidence package.
|
||||
|
||||
R6g send audit and idempotency replay diagnostics is complete:
|
||||
|
||||
@@ -305,3 +305,13 @@ R7 send-file preview tool is complete:
|
||||
- `execution_mode="production"` returns structured blocked metadata; no upload, click, type, hook, network capture, or client mutation is introduced.
|
||||
- Verification passed: focused send-file tests, 10-tool MCP registration test, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`.
|
||||
- Next node: R8 send-file idempotency and audit hardening.
|
||||
|
||||
R8 send-file idempotency and audit hardening is complete:
|
||||
|
||||
- Added `ISPHERE_SEND_FILE_AUDIT_PATH` and `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` support.
|
||||
- Duplicate same target/file/idempotency key returns `duplicate_detected=true` and remains no-send.
|
||||
- Reusing the same idempotency key for a different target or file hash returns structured blocked metadata with `conflict_detected=true`.
|
||||
- Send-file audit/idempotency JSONL stores hashes, target refs, size, result, and side-effect flags; it does not store raw file content, raw local file path, or raw idempotency key.
|
||||
- Production file upload remains blocked with `production_send_enabled=false` and `real_send_attempted=false`.
|
||||
- Verification passed: focused send-file tests, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`.
|
||||
- Next node: R9 send-file upload evidence package.
|
||||
|
||||
@@ -284,7 +284,14 @@ Send-file preview requires an explicit allowed directory. Put only the files you
|
||||
$env:ISPHERE_SEND_FILE_ALLOWED_DIR = "E:\coding\codex\isphere-ai-bridge\runs\send-file-preview"
|
||||
```
|
||||
|
||||
`isphere_send_file` rejects `file_path` values outside that directory. It computes file SHA256 and size, but does not upload, click, type, or return file content.
|
||||
Optional send-file audit/idempotency paths:
|
||||
|
||||
```powershell
|
||||
$env:ISPHERE_SEND_FILE_AUDIT_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-preview.jsonl"
|
||||
$env:ISPHERE_SEND_FILE_IDEMPOTENCY_PATH = "E:\coding\codex\isphere-ai-bridge\runs\send-audit\send-file-idempotency.jsonl"
|
||||
```
|
||||
|
||||
`isphere_send_file` rejects `file_path` values outside that directory. It computes file SHA256 and size, but does not upload, click, type, or return file content. If `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH` is set, duplicate same-key/same-target/same-file preview calls set `duplicate_detected=true`; reusing the same key for a different target or file hash returns blocked metadata with `conflict_detected=true`. The audit/idempotency JSONL stores file hash, size, target ref, and hashed idempotency key; it does not store raw file content, raw local file path, or raw idempotency key.
|
||||
|
||||
R6g adds a local diagnostic for replaying the redacted audit/idempotency files without opening iSphere:
|
||||
|
||||
|
||||
@@ -594,19 +594,19 @@ git push gitea main
|
||||
- Duplicate same target/file/idempotency returns `duplicate_detected=true` with no second send.
|
||||
- Reusing the same idempotency key for a different target or file hash returns `conflict_detected=true`.
|
||||
|
||||
- [ ] **Step 1: Write duplicate test**
|
||||
- [x] **Step 1: Write duplicate test**
|
||||
|
||||
Add `TestISphereSendFileDuplicateIdempotencyDetected`: same target, same file hash, same idempotency key called twice; second response has `duplicate_detected=true` and `real_send_attempted=false`.
|
||||
|
||||
- [ ] **Step 2: Write conflict test**
|
||||
- [x] **Step 2: Write conflict test**
|
||||
|
||||
Add `TestISphereSendFileIdempotencyConflictBlocked`: same idempotency key with a different file hash returns `send_status="blocked"` and `conflict_detected=true`.
|
||||
|
||||
- [ ] **Step 3: Implement audit and idempotency state**
|
||||
- [x] **Step 3: Implement audit and idempotency state**
|
||||
|
||||
Reuse the message idempotency pattern or extract a shared small helper only if it reduces duplication without changing message behavior.
|
||||
|
||||
- [ ] **Step 4: Verify and commit**
|
||||
- [x] **Step 4: Verify and commit**
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
@@ -621,6 +621,15 @@ git push gitea main
|
||||
|
||||
**Acceptance gate:** File upload remains blocked, but duplicate/conflict behavior is deterministic.
|
||||
|
||||
**R8 Result:**
|
||||
|
||||
- Added send-file audit and idempotency state with `ISPHERE_SEND_FILE_AUDIT_PATH` and `ISPHERE_SEND_FILE_IDEMPOTENCY_PATH`.
|
||||
- Duplicate same target/file/idempotency key now returns `duplicate_detected=true` and remains planned/no-send.
|
||||
- Reusing the same idempotency key for a different target or file hash returns `send_status="blocked"` and `conflict_detected=true`.
|
||||
- Send-file audit/idempotency JSONL stores file hash, size, target ref, result, side-effect flags, and hashed idempotency key; it does not store raw file content, raw local file path, or raw idempotency key.
|
||||
- `scripts/verify-go-mcp.ps1` now asserts `send_file_duplicate_detected=true` and `send_file_conflict_blocked=true` while keeping `send_file_production_enabled=false`.
|
||||
- Verification passed: `git diff --check`, `go test ./internal/tools -run TestISphereSendFile -v`, `go test ./...`, `go build ./cmd/isphere-mcp`, `scripts\verify-go-mcp.ps1`, and `go run ./cmd/isphere-capability-smoke`.
|
||||
|
||||
---
|
||||
|
||||
### R9: Send-file upload evidence package
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_FILE_ALLOWED_DIR"} {
|
||||
for _, key := range []string{"ISPHERE_PACKET_LOG_FILE", "ISPHERE_PACKET_LOG_DIR", "ISPHERE_MSGLIB_SIDECAR_EXE", "ISPHERE_MSGLIB_SQLITE_DLL", "ISPHERE_MSGLIB_DB", "ISPHERE_MSGLIB_PASSWORD", "ISPHERE_SEND_FILE_ALLOWED_DIR", "ISPHERE_SEND_FILE_AUDIT_PATH", "ISPHERE_SEND_FILE_IDEMPOTENCY_PATH"} {
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
fail("server/env", err)
|
||||
}
|
||||
@@ -116,6 +116,12 @@ func main() {
|
||||
if err := os.Setenv("ISPHERE_SEND_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-idempotency.jsonl")); err != nil {
|
||||
fail("server/send_state", err)
|
||||
}
|
||||
if err := os.Setenv("ISPHERE_SEND_FILE_AUDIT_PATH", filepath.Join(sendStateDir, "send-file-preview.jsonl")); err != nil {
|
||||
fail("server/send_file_state", err)
|
||||
}
|
||||
if err := os.Setenv("ISPHERE_SEND_FILE_IDEMPOTENCY_PATH", filepath.Join(sendStateDir, "send-file-idempotency.jsonl")); err != nil {
|
||||
fail("server/send_file_state", err)
|
||||
}
|
||||
sendFileDir, err := os.MkdirTemp("", "isphere-send-file-*")
|
||||
if err != nil {
|
||||
fail("server/send_file_state", err)
|
||||
@@ -235,7 +241,7 @@ func main() {
|
||||
defaultGroupCount := verifySearchGroups(ctx, session, "project", "tools/call isphere_search_groups default", 0)
|
||||
defaultFileCount := verifyReceiveFiles(ctx, session, "report", "tools/call isphere_receive_files default", 0, "", "")
|
||||
sendPreviewOK, productionSendEnabled := verifySendMessagePreview(ctx, session)
|
||||
sendFilePreviewOK, sendFileProductionEnabled := verifySendFilePreview(ctx, session, sendFilePath)
|
||||
sendFilePreviewOK, sendFileProductionEnabled, sendFileDuplicateDetected, sendFileConflictBlocked := verifySendFilePreview(ctx, session, sendFilePath)
|
||||
configuredReceiveCount, configuredContactCount, configuredGroupCount, configuredFileCount := verifyConfiguredReceiveSource(ctx)
|
||||
configuredDirReceiveCount, configuredDirContactCount, configuredDirGroupCount, configuredDirFileCount := verifyConfiguredDirectorySource(ctx)
|
||||
|
||||
@@ -267,6 +273,8 @@ func main() {
|
||||
"production_send_enabled": productionSendEnabled,
|
||||
"send_file_preview_tool_present": sendFilePreviewOK,
|
||||
"send_file_production_enabled": sendFileProductionEnabled,
|
||||
"send_file_duplicate_detected": sendFileDuplicateDetected,
|
||||
"send_file_conflict_blocked": sendFileConflictBlocked,
|
||||
"msglib_configured": false,
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
@@ -586,9 +594,9 @@ func verifySendMessagePreview(ctx context.Context, session *mcp.ClientSession) (
|
||||
return true, false
|
||||
}
|
||||
|
||||
func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, filePath string) (bool, bool) {
|
||||
func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, filePath string) (bool, bool, bool, bool) {
|
||||
fileHash := sha256FileHexForHarness(filePath)
|
||||
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{
|
||||
previewArgs := map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
@@ -596,7 +604,8 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file
|
||||
"idempotency_key": "verify-file-preview-idempotency-key",
|
||||
"execution_mode": "preview",
|
||||
"preview": true,
|
||||
}})
|
||||
}
|
||||
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: previewArgs})
|
||||
if err != nil {
|
||||
fail("tools/call isphere_send_file preview", err)
|
||||
}
|
||||
@@ -619,6 +628,62 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file
|
||||
fail("tools/call isphere_send_file preview", fmt.Errorf("unexpected send-file side effects: %s", encoded))
|
||||
}
|
||||
|
||||
duplicateResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: previewArgs})
|
||||
if err != nil {
|
||||
fail("tools/call isphere_send_file duplicate", err)
|
||||
}
|
||||
if duplicateResult.IsError {
|
||||
fail("tools/call isphere_send_file duplicate", fmt.Errorf("tool returned isError=true: %#v", duplicateResult.Content))
|
||||
}
|
||||
var duplicatePayload map[string]any
|
||||
duplicateEncoded, _ := json.Marshal(duplicateResult.StructuredContent)
|
||||
if err := json.Unmarshal(duplicateEncoded, &duplicatePayload); err != nil {
|
||||
fail("tools/call isphere_send_file duplicate", fmt.Errorf("decode structuredContent %s: %w", duplicateEncoded, err))
|
||||
}
|
||||
duplicateIdempotency, ok := duplicatePayload["idempotency"].(map[string]any)
|
||||
if !ok || duplicatePayload["ok"] != true || duplicatePayload["send_status"] != "planned" || duplicatePayload["real_send_attempted"] != false || duplicateIdempotency["duplicate_detected"] != true || duplicateIdempotency["conflict_detected"] != false {
|
||||
fail("tools/call isphere_send_file duplicate", fmt.Errorf("duplicate idempotency mismatch: %s", duplicateEncoded))
|
||||
}
|
||||
|
||||
conflictPath := filepath.Join(filepath.Dir(filePath), "redacted-conflict.txt")
|
||||
if err := os.WriteFile(conflictPath, []byte("world"), 0o600); err != nil {
|
||||
fail("tools/call isphere_send_file conflict_fixture", err)
|
||||
}
|
||||
conflictKey := "verify-file-conflict-idempotency-key"
|
||||
if _, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": fileHash,
|
||||
"idempotency_key": conflictKey,
|
||||
"execution_mode": "preview",
|
||||
}}); err != nil {
|
||||
fail("tools/call isphere_send_file conflict_first", err)
|
||||
}
|
||||
conflictResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
"file_path": conflictPath,
|
||||
"file_sha256": sha256FileHexForHarness(conflictPath),
|
||||
"idempotency_key": conflictKey,
|
||||
"execution_mode": "preview",
|
||||
}})
|
||||
if err != nil {
|
||||
fail("tools/call isphere_send_file conflict", err)
|
||||
}
|
||||
if conflictResult.IsError {
|
||||
fail("tools/call isphere_send_file conflict", fmt.Errorf("tool returned isError=true: %#v", conflictResult.Content))
|
||||
}
|
||||
var conflictPayload map[string]any
|
||||
conflictEncoded, _ := json.Marshal(conflictResult.StructuredContent)
|
||||
if err := json.Unmarshal(conflictEncoded, &conflictPayload); err != nil {
|
||||
fail("tools/call isphere_send_file conflict", fmt.Errorf("decode structuredContent %s: %w", conflictEncoded, err))
|
||||
}
|
||||
conflictIdempotency, ok := conflictPayload["idempotency"].(map[string]any)
|
||||
if !ok || conflictPayload["ok"] != false || conflictPayload["send_status"] != "blocked" || conflictPayload["real_send_attempted"] != false || conflictIdempotency["duplicate_detected"] != false || conflictIdempotency["conflict_detected"] != true {
|
||||
fail("tools/call isphere_send_file conflict", fmt.Errorf("conflict idempotency mismatch: %s", conflictEncoded))
|
||||
}
|
||||
|
||||
productionResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "isphere_send_file", Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "sender@imopenfire1-lanzhou",
|
||||
@@ -645,7 +710,7 @@ func verifySendFilePreview(ctx context.Context, session *mcp.ClientSession, file
|
||||
if !ok || productionSideEffects["sent_file"] != false || productionSideEffects["uploaded_file"] != false || productionSideEffects["captured_network"] != false {
|
||||
fail("tools/call isphere_send_file production_blocked", fmt.Errorf("unexpected production file side effects: %s", productionEncoded))
|
||||
}
|
||||
return true, false
|
||||
return true, false, true, true
|
||||
}
|
||||
|
||||
func verifySearchContacts(ctx context.Context, session *mcp.ClientSession, query string, step string, wantCount int) int {
|
||||
@@ -856,6 +921,8 @@ func fail(step string, err error) {
|
||||
Assert-True ($script:harnessJson.production_send_enabled -eq $false) "SDK harness production_send_enabled mismatch: $($script:harnessJson.production_send_enabled)"
|
||||
Assert-True ($script:harnessJson.send_file_preview_tool_present -eq $true) "SDK harness send_file_preview_tool_present mismatch: $($script:harnessJson.send_file_preview_tool_present)"
|
||||
Assert-True ($script:harnessJson.send_file_production_enabled -eq $false) "SDK harness send_file_production_enabled mismatch: $($script:harnessJson.send_file_production_enabled)"
|
||||
Assert-True ($script:harnessJson.send_file_duplicate_detected -eq $true) "SDK harness send_file_duplicate_detected mismatch: $($script:harnessJson.send_file_duplicate_detected)"
|
||||
Assert-True ($script:harnessJson.send_file_conflict_blocked -eq $true) "SDK harness send_file_conflict_blocked mismatch: $($script:harnessJson.send_file_conflict_blocked)"
|
||||
|
||||
[ordered]@{
|
||||
ok = $true
|
||||
@@ -880,6 +947,8 @@ func fail(step string, err error) {
|
||||
production_send_enabled = $script:harnessJson.production_send_enabled
|
||||
send_file_preview_tool_present = $script:harnessJson.send_file_preview_tool_present
|
||||
send_file_production_enabled = $script:harnessJson.send_file_production_enabled
|
||||
send_file_duplicate_detected = $script:harnessJson.send_file_duplicate_detected
|
||||
send_file_conflict_blocked = $script:harnessJson.send_file_conflict_blocked
|
||||
packet_log_file_configured = $script:harnessJson.packet_log_file_configured
|
||||
packet_log_dir_configured = $script:harnessJson.packet_log_dir_configured
|
||||
python_runtime_required = $false
|
||||
|
||||
Reference in New Issue
Block a user