diff --git a/docs/current-status-card.md b/docs/current-status-card.md index cb2371d..c3b39a1 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -58,7 +58,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## Current loop -Current loop: `R6g send audit and idempotency replay diagnostics complete; next R6h central production gate policy for send message`. +Current loop: `R6h central production gate policy for send message complete; next R6i B-route connector adapter shell`. Active continuous execution plan: `docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md`. @@ -239,8 +239,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: R6g send audit and idempotency replay diagnostics. -- Next active round: R6h central production gate policy for send message. +- Last completed round: R6h central production gate policy for send message. +- Next active round: R6i B-route connector adapter shell. R6g send audit and idempotency replay diagnostics is complete: @@ -250,3 +250,12 @@ R6g send audit and idempotency replay diagnostics is complete: - Added `TestISphereSendMessageAuditRedactionIsStable` proving file-backed audit JSONL does not store raw message body or raw idempotency key. - Standard verification still reports 9 tools and `production_send_enabled=false`. - Next node: R6h central production gate policy for send message. + +R6h central production gate policy for send message is complete: + +- Added `SendMessageGatePolicy` and `LoadSendMessageGatePolicy`. +- Gate reason codes are now explicit: `send_disabled_by_default`, `send_evidence_missing`, `send_connector_missing`, and `send_gate_passed`. +- Default production-blocked `isphere_send_message` responses and audit events include `gate_reason_code` and `gate_reason_message`. +- `ISPHERE_SEND_PRODUCTION_ENABLED=1` is recognized by policy tests, but it is not enough by itself; evidence and connector gates must also pass. +- Standard verification still reports 9 tools and `production_send_enabled=false`. +- Next node: R6i B-route connector adapter shell. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 741b5ae..64dcdf4 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -285,6 +285,15 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-send-audit-id The diagnostic prints sanitized counts and booleans: `audit_records`, `idempotency_records`, `duplicate_detected`, `conflict_detected`, `raw_body_present`, and `raw_idempotency_key_present`. It exits non-zero if a raw message body field or raw idempotency-key field is found. +R6h centralizes the production-send gate. Default `execution_mode="production"` responses include machine-readable `gate_reason_code` and `gate_reason_message`. The valid gate codes are: + +- `send_disabled_by_default` +- `send_evidence_missing` +- `send_connector_missing` +- `send_gate_passed` + +`ISPHERE_SEND_PRODUCTION_ENABLED=1` is recognized by the gate policy, but it is not sufficient by itself: the evidence gate and connector gate must also pass before production can be allowed. In the default MCP server path, `production_send_enabled` remains `false`. + ## 10. Current tool surface and next-stage route The current runbook verifies nine tools: diff --git a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md index ad7e5bb..81484ac 100644 --- a/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md +++ b/docs/superpowers/plans/2026-07-10-r6f-r14-continuous-execution-plan.md @@ -239,7 +239,7 @@ git push gitea main - Produces: `LoadSendMessageGatePolicy(env map[string]string, evidenceGatePass bool, connectorConfigured bool) SendMessageGatePolicy`. - Valid reason codes: `send_evidence_missing`, `send_connector_missing`, `send_disabled_by_default`, `send_gate_passed`. -- [ ] **Step 1: Write table tests** +- [x] **Step 1: Write table tests** Create `TestLoadSendMessageGatePolicy` with cases for default disabled, env set but evidence missing, evidence pass but connector missing, and all pass. @@ -249,15 +249,15 @@ go test ./internal/tools -run TestLoadSendMessageGatePolicy -v Expected before implementation: FAIL because the gate type does not exist. -- [ ] **Step 2: Implement gate policy** +- [x] **Step 2: Implement gate policy** Create `send_message_gate.go` with the exact type/function and reason-code order from the test. -- [ ] **Step 3: Route blocked production through the gate** +- [x] **Step 3: Route blocked production through the gate** Modify `isphere_send_message.go` so blocked production responses include `gate_reason_code` and `gate_reason_message` from `SendMessageGatePolicy`. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** ```powershell git diff --check @@ -272,6 +272,13 @@ git push gitea main **Acceptance gate:** The default runtime stays blocked, but the response explains the exact reason in machine-readable fields. +**R6h Result:** + +- Added `internal/tools/send_message_gate.go` with `SendMessageGatePolicy` and `LoadSendMessageGatePolicy`. +- Added table tests for `send_disabled_by_default`, `send_evidence_missing`, `send_connector_missing`, and `send_gate_passed`. +- Default `isphere_send_message` production-blocked responses and audit events now include `gate_reason_code` and `gate_reason_message`. +- `ISPHERE_SEND_PRODUCTION_ENABLED=1` is recognized by policy tests, but the default MCP runtime still has no real connector and reports `production_send_enabled=false`. + --- ### R6i: B-route connector adapter shell diff --git a/internal/tools/isphere_send_message.go b/internal/tools/isphere_send_message.go index 4f8796b..a275400 100644 --- a/internal/tools/isphere_send_message.go +++ b/internal/tools/isphere_send_message.go @@ -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 diff --git a/internal/tools/isphere_send_message_test.go b/internal/tools/isphere_send_message_test.go index 296ff49..2695976 100644 --- a/internal/tools/isphere_send_message_test.go +++ b/internal/tools/isphere_send_message_test.go @@ -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() diff --git a/internal/tools/send_message_gate.go b/internal/tools/send_message_gate.go new file mode 100644 index 0000000..d7b0c05 --- /dev/null +++ b/internal/tools/send_message_gate.go @@ -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" +} diff --git a/internal/tools/send_message_gate_test.go b/internal/tools/send_message_gate_test.go new file mode 100644 index 0000000..de7b66f --- /dev/null +++ b/internal/tools/send_message_gate_test.go @@ -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) + } + }) + } +}