feat: centralize send message production gate

This commit is contained in:
zhaoyilun
2026-07-10 20:35:46 +08:00
parent 2a7087218b
commit b08b0e01bd
7 changed files with 187 additions and 22 deletions

View File

@@ -61,6 +61,8 @@ type SendMessageAuditEvent struct {
AckRef string `json:"ack_ref,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
GateReasonCode string `json:"gate_reason_code,omitempty"`
GateReasonMessage string `json:"gate_reason_message,omitempty"`
AuditRef string `json:"audit_ref"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
@@ -130,7 +132,8 @@ func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, aud
return nil, nil, err
}
finished := time.Now().UTC()
response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil)
gatePolicy := currentSendMessageGatePolicy(connector != nil)
response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil, gatePolicy)
decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event))
if err != nil {
return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err)
@@ -161,6 +164,12 @@ func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore {
return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")}
}
func currentSendMessageGatePolicy(connectorConfigured bool) SendMessageGatePolicy {
return LoadSendMessageGatePolicy(map[string]string{
EnvSendMessageProductionEnabled: os.Getenv(EnvSendMessageProductionEnabled),
}, false, connectorConfigured)
}
func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
if strings.TrimSpace(s.path) == "" {
return nil
@@ -343,12 +352,14 @@ func normalizeSendMessageExecutionMode(value string) (string, error) {
}
}
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool) (map[string]any, SendMessageAuditEvent) {
func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool, gatePolicy SendMessageGatePolicy) (map[string]any, SendMessageAuditEvent) {
sideEffects := sendMessageNoSideEffects()
status := "planned"
stage := sendMessagePreviewStage
ok := true
blockedReason := any(nil)
gateReasonCode := ""
gateReasonMessage := ""
if input.ExecutionMode == sendMessageProductionMode {
if connectorAvailable {
stage = sendMessagePreviewStage
@@ -356,7 +367,9 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
ok = false
status = "blocked"
stage = sendMessageBlockedStage
blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
gateReasonCode = gatePolicy.ReasonCode
gateReasonMessage = gatePolicy.ReasonMessage
blockedReason = gateReasonMessage
}
}
auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256)
@@ -374,6 +387,8 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
IdempotencyKeySHA256: input.IdempotencyKeySHA256,
SendStatus: status,
Result: status,
GateReasonCode: gateReasonCode,
GateReasonMessage: gateReasonMessage,
AuditRef: auditRef,
StartedAt: started.Format(time.RFC3339Nano),
FinishedAt: finished.Format(time.RFC3339Nano),
@@ -387,6 +402,8 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"gate_reason_code": gateReasonCode,
"gate_reason_message": gateReasonMessage,
"production_send_enabled": false,
"target": map[string]any{
"target_type": input.TargetType,
@@ -406,18 +423,20 @@ func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Ti
},
"side_effects": sideEffects,
"audit": map[string]any{
"tool": ToolNameSendMessage,
"source": sendMessageConnector,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"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,
"tool": ToolNameSendMessage,
"source": sendMessageConnector,
"execution_mode": input.ExecutionMode,
"connector": sendMessageConnector,
"connector_mode": sendMessageConnectorMode,
"connector_stage": stage,
"gate_reason_code": gateReasonCode,
"gate_reason_message": gateReasonMessage,
"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

View File

@@ -147,6 +147,47 @@ func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
}
}
func TestISphereSendMessageProductionBlockedIncludesGateReason(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereSendMessageToolWithState(server, audit, newFakeSendMessageIdempotencyStore())
})
defer cleanup()
content := "blocked gate reason"
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": sha256HexForSendMessageTest(content),
"idempotency_key": "idem-gate-reason-1",
"execution_mode": "production",
},
})
if err != nil {
t.Fatalf("call production blocked gate reason: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
SendStatus string `json:"send_status"`
GateReasonCode string `json:"gate_reason_code"`
GateReasonMessage string `json:"gate_reason_message"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode payload %s: %v", payload, err)
}
if decoded.OK || decoded.SendStatus != "blocked" || decoded.GateReasonCode != "send_disabled_by_default" || decoded.GateReasonMessage == "" {
t.Fatalf("production blocked response missing gate reason: %s", payload)
}
if len(audit.events) != 1 || audit.events[0].GateReasonCode != "send_disabled_by_default" || audit.events[0].GateReasonMessage == "" {
t.Fatalf("audit event missing gate reason: %+v", audit.events)
}
}
func TestISphereSendMessageFakeConnectorAcceptedAudit(t *testing.T) {
audit := &fakeSendMessageAuditSink{}
idempotency := newFakeSendMessageIdempotencyStore()

View File

@@ -0,0 +1,47 @@
package tools
import "strings"
const EnvSendMessageProductionEnabled = "ISPHERE_SEND_PRODUCTION_ENABLED"
type SendMessageGatePolicy struct {
ProductionAllowed bool
ReasonCode string
ReasonMessage string
EvidenceGatePass bool
ConnectorConfigured bool
}
func LoadSendMessageGatePolicy(env map[string]string, evidenceGatePass bool, connectorConfigured bool) SendMessageGatePolicy {
policy := SendMessageGatePolicy{
EvidenceGatePass: evidenceGatePass,
ConnectorConfigured: connectorConfigured,
}
if !sendMessageProductionEnabled(env) {
policy.ReasonCode = "send_disabled_by_default"
policy.ReasonMessage = "production send is disabled by default; set ISPHERE_SEND_PRODUCTION_ENABLED=1 only after the evidence and connector gates pass"
return policy
}
if !evidenceGatePass {
policy.ReasonCode = "send_evidence_missing"
policy.ReasonMessage = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass"
return policy
}
if !connectorConfigured {
policy.ReasonCode = "send_connector_missing"
policy.ReasonMessage = "production send is blocked because no validated send connector is configured"
return policy
}
policy.ProductionAllowed = true
policy.ReasonCode = "send_gate_passed"
policy.ReasonMessage = "production send gate passed"
return policy
}
func sendMessageProductionEnabled(env map[string]string) bool {
if env == nil {
return false
}
value := strings.TrimSpace(strings.ToLower(env[EnvSendMessageProductionEnabled]))
return value == "1" || value == "true" || value == "yes"
}

View File

@@ -0,0 +1,33 @@
package tools
import "testing"
func TestLoadSendMessageGatePolicy(t *testing.T) {
cases := []struct {
name string
env map[string]string
evidence bool
connector bool
wantAllowed bool
wantReason string
}{
{name: "default disabled", env: map[string]string{}, evidence: false, connector: false, wantAllowed: false, wantReason: "send_disabled_by_default"},
{name: "env set but evidence missing", env: map[string]string{"ISPHERE_SEND_PRODUCTION_ENABLED": "1"}, evidence: false, connector: true, wantAllowed: false, wantReason: "send_evidence_missing"},
{name: "evidence pass but connector missing", env: map[string]string{"ISPHERE_SEND_PRODUCTION_ENABLED": "1"}, evidence: true, connector: false, wantAllowed: false, wantReason: "send_connector_missing"},
{name: "all pass", env: map[string]string{"ISPHERE_SEND_PRODUCTION_ENABLED": "1"}, evidence: true, connector: true, wantAllowed: true, wantReason: "send_gate_passed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := LoadSendMessageGatePolicy(tc.env, tc.evidence, tc.connector)
if got.ProductionAllowed != tc.wantAllowed || got.ReasonCode != tc.wantReason {
t.Fatalf("LoadSendMessageGatePolicy() = %+v, want allowed=%v reason=%q", got, tc.wantAllowed, tc.wantReason)
}
if got.ReasonMessage == "" {
t.Fatalf("ReasonMessage is empty for %+v", got)
}
if got.EvidenceGatePass != tc.evidence || got.ConnectorConfigured != tc.connector {
t.Fatalf("evidence/connector fields = %+v, want evidence=%v connector=%v", got, tc.evidence, tc.connector)
}
})
}
}