Files
zzet--gortex/internal/mcp/change_contract_riskgate.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

189 lines
5.6 KiB
Go

package mcp
import (
"context"
"fmt"
"os"
"sort"
"time"
mcp "github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/persistence"
)
// QRT-6: a risk-gated modification guard. Load-bearing symbols (high fan-in /
// high centrality) require a prior, acknowledged impact review with a TTL.
// The ack is not a side file — it is a development memory keyed on the symbol,
// so it is graph state that survives daemon restarts and surfaces for the next
// agent. The gate emits a refuse verdict; a pretooluse hook enforces it.
const (
riskAckTag = "risk-ack"
riskGateCallerDefault = 8
riskGatePRThreshold = 0.66
defaultRiskAckTTL = 4 * time.Hour
riskGateMemImportance = 4
)
// riskGateEnabled reports whether the per-symbol risk gate is active for this
// call — either the request opted in or GORTEX_RISK_GATE is set.
func riskGateEnabled(req mcp.CallToolRequest) bool {
if req.GetBool("risk_gate", false) {
return true
}
switch os.Getenv("GORTEX_RISK_GATE") {
case "", "0", "false", "off", "no":
return false
default:
return true
}
}
// riskAckTTL is how long a recorded ack stays fresh. Overridable via
// GORTEX_RISK_ACK_TTL (a Go duration like "2h").
func riskAckTTL() time.Duration {
if v := os.Getenv("GORTEX_RISK_ACK_TTL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return defaultRiskAckTTL
}
func riskGateCallerThreshold() int {
if v := os.Getenv("GORTEX_RISK_GATE_MIN_CALLERS"); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
return n
}
}
return riskGateCallerDefault
}
// isRiskGated reports whether a symbol is load-bearing enough to require an ack.
func (s *Server) isRiskGated(n *graph.Node) bool {
if n == nil {
return false
}
fanIn, _ := computeFanInOut(s.graph, []*graph.Node{n})
if fanIn[n.ID] >= riskGateCallerThreshold() {
return true
}
if s.pageRank != nil && s.pageRank.Max > 0 {
if s.pageRank.ScoreOf(n.ID)/s.pageRank.Max >= riskGatePRThreshold {
return true
}
}
return false
}
// riskGatedSymbols returns the changed symbols that require an ack.
func (s *Server) riskGatedSymbols(p *prediction) []*graph.Node {
var out []*graph.Node
for _, n := range p.nodes {
if s.isRiskGated(n) {
out = append(out, n)
}
}
return out
}
// hasFreshAck reports whether a non-expired risk-ack memory exists for a symbol.
func (s *Server) hasFreshAck(id string) bool {
store := s.resolveMemoryStore("workspace")
if store == nil {
return false
}
ttl := riskAckTTL()
for _, m := range store.Query(MemoryQueryFilter{Tag: riskAckTag, SymbolID: id}) {
if time.Since(m.UpdatedAt) < ttl {
return true
}
}
return false
}
// recordRiskAck stores (or refreshes) the ack memory for a symbol.
func (s *Server) recordRiskAck(id, author string, risk changeRisk) (string, error) {
store := s.resolveMemoryStore("workspace")
if store == nil {
return "", fmt.Errorf("no memory store available to record the ack")
}
body := fmt.Sprintf("Risk-gate ack for %s — change impact reviewed (risk score %d, blast %d). Valid for %s.",
id, risk.Score, risk.BlastSize, riskAckTTL())
return store.Save(persistence.MemoryEntry{
Kind: "constraint",
Source: "change_contract",
Body: body,
Tags: []string{riskAckTag},
SymbolIDs: []string{id},
Importance: riskGateMemImportance,
AuthorAgent: author,
})
}
// riskGateReasons checks each gated symbol for a fresh ack and returns refuse
// reasons for those lacking one.
func (s *Server) riskGateReasons(p *prediction, risk changeRisk) []changeReason {
var reasons []changeReason
for _, n := range s.riskGatedSymbols(p) {
if s.hasFreshAck(n.ID) {
reasons = append(reasons, changeReason{
Family: "risk_gate",
Severity: "info",
Message: fmt.Sprintf("%s is risk-gated and has a fresh ack — cleared", n.Name),
Confidence: 1,
Symbol: n.ID,
})
continue
}
reasons = append(reasons, changeReason{
Family: "risk_gate",
Severity: "error",
Message: fmt.Sprintf("%s is risk-gated (load-bearing) and has no fresh impact-review ack — acknowledge with `change_contract ack:true symbols:%s` (ack valid %s)", n.Name, n.ID, riskAckTTL()),
Confidence: 0.9,
Symbol: n.ID,
})
}
return reasons
}
// handleRiskAck records acks for the changed set and returns a confirmation.
func (s *Server) handleRiskAck(ctx context.Context, req mcp.CallToolRequest, p *prediction) (*mcp.CallToolResult, error) {
author := s.ackAuthor(ctx)
risk := s.scoreChangeRisk(p)
// Ack every changed symbol the caller named (an explicit acknowledgement
// of this change set), so a subsequent gated run on any of them clears.
ids := p.changedIDs
if len(ids) == 0 {
return mcp.NewToolResultError("change_contract ack: no changed symbols to acknowledge"), nil
}
acked := make([]string, 0, len(ids))
for _, id := range ids {
if _, err := s.recordRiskAck(id, author, risk); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
acked = append(acked, id)
}
sort.Strings(acked)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"acked": acked,
"ttl": riskAckTTL().String(),
"expires_at": time.Now().Add(riskAckTTL()).UTC().Format(time.RFC3339),
"note": "Risk-gate acks recorded as development memories. A change_contract run with risk_gate:true now clears these symbols until the TTL elapses.",
})
}
// ackAuthor returns the MCP client name for ack provenance, or a default.
func (s *Server) ackAuthor(ctx context.Context) string {
if sess := s.sessionFor(ctx); sess != nil {
if n := sess.snapshotClientName(); n != "" {
return n
}
}
return "change_contract"
}