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
608 lines
22 KiB
Go
608 lines
22 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/profiles"
|
|
)
|
|
|
|
// ToolPolicyConfig is the operator-facing description of a restricted
|
|
// tool surface: a named preset, per-tool allow/deny deltas, and a mode.
|
|
// It is the wire between the `mcp.tools` config block, the GORTEX_TOOLS
|
|
// / GORTEX_TOOLS_MODE env overrides, and the resolved in-memory
|
|
// toolPolicy. Zero value (empty preset, no deltas) means "no
|
|
// restriction" — the full surface.
|
|
type ToolPolicyConfig struct {
|
|
Preset string
|
|
Mode string // "hide" | "defer" — default hide
|
|
Allow []string
|
|
Deny []string
|
|
}
|
|
|
|
const (
|
|
// toolPolicyModeHide removes non-allowed tools from tools/list and
|
|
// hard-blocks calls to them. The minimal, locked-down surface a
|
|
// headless harness wants — works identically on every client.
|
|
toolPolicyModeHide = "hide"
|
|
// toolPolicyModeDefer keeps non-allowed tools out of the cold
|
|
// tools/list but still reachable via the tools_search discovery
|
|
// tool (which promotes on demand). Only effective on clients that
|
|
// honour notifications/tools/list_changed.
|
|
toolPolicyModeDefer = "defer"
|
|
|
|
toolPresetEnv = "GORTEX_TOOLS"
|
|
toolPresetModeEnv = "GORTEX_TOOLS_MODE"
|
|
)
|
|
|
|
// corePresetTools is the curated "classic developer" surface published
|
|
// eagerly by default — the workhorses a regular dev reaches for across
|
|
// the whole cycle: orient, search/navigate, read, edit, verify/test,
|
|
// analyze, review, and the mandatory memory steps. It is the allow-set
|
|
// of the `core` preset, which is the server default (in defer mode):
|
|
// these ship in the cold tools/list, everything else is deferred behind
|
|
// tools_search. Sized to cover the documented mandatory workflow end to
|
|
// end so the common task never needs a discovery round-trip.
|
|
//
|
|
// tool_profile and tools_search are always kept on top of any preset
|
|
// (isAlwaysKeptTool), so they are intentionally absent here.
|
|
//
|
|
// NB: this is the DEFAULT-surface roster, distinct from
|
|
// lazy_tools.go::hotEagerTools (the GORTEX_LAZY_TOOLS=1 eager set) — the
|
|
// two answer different questions and are allowed to diverge.
|
|
var corePresetTools = []string{
|
|
// orient — explore is the one-shot localization verb, the loud first
|
|
// move for any task-shaped request (ranked neighborhood + source +
|
|
// call paths in one call). index_health is the cheap liveness check
|
|
// the workflow recommends, so it ships eagerly too (no discovery
|
|
// round-trip for the documented first step). get_active_project stays
|
|
// deferred: it is only registered in multi-repo mode, so it can't be
|
|
// an unconditional core tool.
|
|
"explore", "smart_context", "get_repo_outline", "graph_stats", "index_health",
|
|
// search / navigate
|
|
"search_symbols", "search_text", "find_files", "find_usages",
|
|
"find_implementations", "get_callers", "get_call_chain",
|
|
"get_dependencies", "get_dependents",
|
|
// read
|
|
"read_file", "get_symbol", "get_symbol_source", "get_file_summary",
|
|
"get_editing_context",
|
|
// edit
|
|
"edit_file", "edit_symbol", "write_file", "batch_edit", "rename_symbol",
|
|
// verify / test
|
|
"verify_change", "get_diagnostics", "check_guards", "get_test_targets",
|
|
// analyze (61-kind dispatcher — one name, broad coverage)
|
|
"analyze",
|
|
// review / commit
|
|
"detect_changes", "diff_context", "review",
|
|
// memory (the mandatory save/recall/surface workflow)
|
|
"surface_memories", "save_note", "store_memory",
|
|
}
|
|
|
|
// agentFloorTools is the measured coding-agent working set — the tools a
|
|
// headless coding agent actually reaches for across a navigate → read →
|
|
// edit → verify cycle, from adoption probes and dogfooding. This is the
|
|
// FLOOR: it never shrinks, and it is the default eager surface for known
|
|
// coding-agent clients. Everything else defers behind tools_search (still
|
|
// callable by name via promote-on-demand). tool_profile / tools_search are
|
|
// always kept on top (isAlwaysKeptTool).
|
|
var agentFloorTools = []string{
|
|
// orient — explore is the one-shot localization verb: the obvious
|
|
// opening move for any task-shaped request. It returns the ranked
|
|
// neighborhood (symbols + source + call paths + file map) in one
|
|
// call, folding the granular search/read/callers loop the agent would
|
|
// otherwise grind through into a single turn.
|
|
"explore", "smart_context", "index_health",
|
|
// search / navigate
|
|
"search_symbols", "find_usages", "find_implementations",
|
|
"get_callers", "get_call_chain", "get_dependencies", "get_dependents",
|
|
// read — batch_symbols is the follow-up reader after explore: it
|
|
// fetches many bodies in one call, so a residual read loop collapses
|
|
// into a single turn.
|
|
"get_symbol_source", "batch_symbols", "get_file_summary",
|
|
"get_editing_context", "read_file",
|
|
// edit / verify
|
|
"edit_file", "write_file", "verify_change",
|
|
}
|
|
|
|
// agentTailTools is the workflow-mandated memory trio — the negotiable
|
|
// tail added on top of the floor ONLY when the byte budget holds. It does
|
|
// not: even under aggressive compaction the trio's rich descriptions push
|
|
// the cold tools/list past its ceiling, so the trio stays DEFERRED (still
|
|
// reachable by name via tools_search / promote-on-demand and surfaced by
|
|
// the related_tools cue). Cut from here first, never from the floor.
|
|
var agentTailTools = []string{
|
|
"distill_session", "surface_memories", "store_memory",
|
|
}
|
|
|
|
// agentPresetTools is the eager surface of the `agent` preset: exactly the
|
|
// floor. The tail is deferred (see agentTailTools). Kept lean enough that
|
|
// the cold tools/list stays a few thousand tokens — see the byte-ceiling
|
|
// regression test (TestAgentPresetByteCeiling).
|
|
var agentPresetTools = append([]string{}, agentFloorTools...)
|
|
|
|
// editPresetTools is the minimal headless code-editing surface: orient,
|
|
// navigate, mutate, verify. Sized so an agent can edit code safely on a
|
|
// remote box without the full 170-tool catalogue. tool_profile and
|
|
// tools_search are always kept on top of any preset (isAlwaysKeptTool).
|
|
var editPresetTools = []string{
|
|
// orient + read
|
|
"smart_context", "get_editing_context", "read_file", "get_symbol_source",
|
|
"get_file_summary", "get_symbol",
|
|
// navigate
|
|
"search_symbols", "search_text", "find_files", "find_usages", "get_callers",
|
|
// mutate
|
|
"edit_file", "edit_symbol", "write_file", "batch_edit", "rename_symbol",
|
|
// verify
|
|
"verify_change", "get_test_targets", "check_guards", "get_diagnostics",
|
|
// orientation
|
|
"graph_stats",
|
|
}
|
|
|
|
// navPresetTools is the read-only navigation / exploration surface — no
|
|
// editing tools at all.
|
|
var navPresetTools = []string{
|
|
"explore",
|
|
"smart_context", "get_editing_context", "read_file", "get_symbol_source",
|
|
"get_file_summary", "get_symbol",
|
|
"search_symbols", "search_text", "find_files", "find_usages",
|
|
"find_implementations", "find_overrides", "get_callers", "get_call_chain",
|
|
"get_dependencies", "get_dependents", "get_repo_outline", "graph_stats",
|
|
}
|
|
|
|
// localizationPresetTools is the eager surface of the `localization`
|
|
// preset — the lean "where is the code that does X" working set. The
|
|
// list lives in internal/profiles (the instruction-profile table) so
|
|
// the tool surface and the localization instructions body render from
|
|
// the same slice and cannot drift.
|
|
var localizationPresetTools = profiles.LocalizationEagerTools()
|
|
|
|
// builtinToolPresetSet resolves a preset name to its explicit allow-set.
|
|
// A nil set with denyMutating=false is the sentinel for "no explicit
|
|
// restriction" (the full surface); `readonly` carries denyMutating=true
|
|
// instead of an explicit list so it tracks the authoritative
|
|
// daemon.MutatingTools set as it evolves. known=false flags an
|
|
// unrecognised preset name.
|
|
func builtinToolPresetSet(name string) (set map[string]bool, denyMutating, known bool) {
|
|
switch name {
|
|
case "", "full", "all":
|
|
return nil, false, true
|
|
case "core", "default", "classic":
|
|
return toToolSet(corePresetTools), false, true
|
|
case "agent", "coding-agent":
|
|
return toToolSet(agentPresetTools), false, true
|
|
case "readonly", "read-only", "read_only":
|
|
return nil, true, true
|
|
case "edit", "editor", "edit-harness":
|
|
return toToolSet(editPresetTools), false, true
|
|
case "nav", "navigate", "explore":
|
|
return toToolSet(navPresetTools), false, true
|
|
case "localization", "locate", "find":
|
|
return toToolSet(localizationPresetTools), false, true
|
|
default:
|
|
return nil, false, false
|
|
}
|
|
}
|
|
|
|
// builtinPresetNames lists the recognised preset names for diagnostics.
|
|
var builtinPresetNames = []string{"agent", "core", "full", "readonly", "edit", "nav", "localization"}
|
|
|
|
// toolPolicy is the resolved, in-memory restriction applied to the tool
|
|
// surface by the lazy registry (defer mode) and toolSurfaceFilter /
|
|
// checkToolGate (hide mode). The zero/nil policy allows everything.
|
|
type toolPolicy struct {
|
|
preset string
|
|
mode string
|
|
explicit map[string]bool // non-nil => base surface is exactly this set
|
|
denyMutating bool // drop daemon.MutatingTools (the `readonly` preset)
|
|
allow map[string]bool // force-include (overrides the preset)
|
|
deny map[string]bool // force-exclude (overrides everything)
|
|
active bool
|
|
// lean, set for the `agent` preset, applies an extra per-parameter
|
|
// description compaction on this session's tools/list so the coding-
|
|
// agent surface stays inside its byte ceiling. The full parameter prose
|
|
// is always one tools_search / `full` preset away.
|
|
lean bool
|
|
}
|
|
|
|
func toToolSet(names []string) map[string]bool {
|
|
m := make(map[string]bool, len(names))
|
|
for _, n := range names {
|
|
m[n] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
// normalizeToolMode maps a mode string to hide|defer (default hide).
|
|
func normalizeToolMode(mode string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case toolPolicyModeDefer, "lazy", "search":
|
|
return toolPolicyModeDefer
|
|
default:
|
|
return toolPolicyModeHide
|
|
}
|
|
}
|
|
|
|
// newToolPolicy resolves a ToolPolicyConfig into a toolPolicy. An
|
|
// unrecognised preset name is logged and downgraded to the full surface
|
|
// (fail-open — a typo never silently strands an agent with no tools).
|
|
func newToolPolicy(cfg ToolPolicyConfig, logger *zap.Logger) *toolPolicy {
|
|
rawPreset := strings.ToLower(strings.TrimSpace(cfg.Preset))
|
|
allow := toToolSet(cfg.Allow)
|
|
deny := toToolSet(cfg.Deny)
|
|
|
|
var (
|
|
explicit map[string]bool
|
|
denyMutating bool
|
|
label string
|
|
)
|
|
if rawPreset == "" {
|
|
// No named preset. An explicit allow list (e.g. --tools
|
|
// search_symbols,edit_file) IS the surface; otherwise the full
|
|
// surface, minus any deny.
|
|
if len(allow) > 0 {
|
|
explicit = allow
|
|
label = "custom"
|
|
} else {
|
|
label = "full"
|
|
}
|
|
} else if set, dm, known := builtinToolPresetSet(rawPreset); known {
|
|
explicit = set
|
|
denyMutating = dm
|
|
label = rawPreset
|
|
switch label {
|
|
case "all":
|
|
label = "full"
|
|
case "default", "classic":
|
|
label = "core"
|
|
case "coding-agent":
|
|
label = "agent"
|
|
case "locate", "find":
|
|
label = "localization"
|
|
}
|
|
} else {
|
|
// A typo'd preset fails open to the full surface (never strands
|
|
// an agent with no tools); allow deltas stay additive.
|
|
if logger != nil {
|
|
logger.Warn("unknown MCP tool preset; serving the full surface",
|
|
zap.String("preset", cfg.Preset),
|
|
zap.Strings("known", builtinPresetNames))
|
|
}
|
|
label = "full"
|
|
}
|
|
active := explicit != nil || denyMutating || len(allow) > 0 || len(deny) > 0
|
|
return &toolPolicy{
|
|
preset: label,
|
|
mode: normalizeToolMode(cfg.Mode),
|
|
explicit: explicit,
|
|
denyMutating: denyMutating,
|
|
allow: allow,
|
|
deny: deny,
|
|
active: active,
|
|
lean: label == "agent" || label == "localization",
|
|
}
|
|
}
|
|
|
|
// isAlwaysKeptTool: introspection (tool_profile) and discovery
|
|
// (tools_search) stay reachable under every preset so an agent can
|
|
// always see its surface and, in defer mode, discover more. An explicit
|
|
// deny still wins (checked before this in allows).
|
|
func isAlwaysKeptTool(name string) bool {
|
|
return name == "tool_profile" || name == LazyToolsSearchName
|
|
}
|
|
|
|
// allows reports whether name is part of this policy's allowed surface.
|
|
// A nil or inactive policy allows everything.
|
|
func (p *toolPolicy) allows(name string) bool {
|
|
if !p.isActive() {
|
|
return true
|
|
}
|
|
if p.deny[name] {
|
|
return false
|
|
}
|
|
if isAlwaysKeptTool(name) {
|
|
return true
|
|
}
|
|
if p.allow[name] {
|
|
return true
|
|
}
|
|
if p.explicit != nil {
|
|
return p.explicit[name]
|
|
}
|
|
if p.denyMutating && daemon.IsMutating(name) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (p *toolPolicy) isActive() bool { return p != nil && p.active }
|
|
func (p *toolPolicy) hideMode() bool { return p.isActive() && p.mode == toolPolicyModeHide }
|
|
func (p *toolPolicy) deferMode() bool { return p.isActive() && p.mode == toolPolicyModeDefer }
|
|
|
|
// toolPolicyConfigFromEnv reads GORTEX_TOOLS / GORTEX_TOOLS_MODE. The
|
|
// bool reports whether either var was set.
|
|
func toolPolicyConfigFromEnv() (ToolPolicyConfig, bool) {
|
|
spec := strings.TrimSpace(os.Getenv(toolPresetEnv))
|
|
mode := strings.TrimSpace(os.Getenv(toolPresetModeEnv))
|
|
if spec == "" && mode == "" {
|
|
return ToolPolicyConfig{}, false
|
|
}
|
|
cfg := parseToolSpec(spec)
|
|
if mode != "" {
|
|
cfg.Mode = mode
|
|
}
|
|
return cfg, true
|
|
}
|
|
|
|
// isKnownPresetName reports whether name is one of the built-in preset
|
|
// names (full / readonly / edit / nav + aliases).
|
|
func isKnownPresetName(name string) bool {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
if name == "" {
|
|
return false
|
|
}
|
|
_, _, known := builtinToolPresetSet(name)
|
|
return known
|
|
}
|
|
|
|
// parseToolSpec parses a spec into a ToolPolicyConfig. The grammar is:
|
|
//
|
|
// - the first bare token that names a built-in preset is the preset
|
|
// (full / readonly / edit / nav); any further bare tokens are added
|
|
// to the allow set — so "edit,find_files" means the edit preset plus
|
|
// find_files;
|
|
// - if the first bare token is NOT a known preset, every bare token is
|
|
// an explicit tool name — so "search_symbols,edit_file" means exactly
|
|
// those two tools (an expert allow list, no preset);
|
|
// - +name / -name are always allow / deny deltas.
|
|
func parseToolSpec(spec string) ToolPolicyConfig {
|
|
var cfg ToolPolicyConfig
|
|
presetTaken := false
|
|
for _, tok := range strings.Split(spec, ",") {
|
|
tok = strings.TrimSpace(tok)
|
|
if tok == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(tok, "+"):
|
|
cfg.Allow = append(cfg.Allow, strings.TrimPrefix(tok, "+"))
|
|
case strings.HasPrefix(tok, "-"):
|
|
cfg.Deny = append(cfg.Deny, strings.TrimPrefix(tok, "-"))
|
|
case !presetTaken && isKnownPresetName(tok):
|
|
cfg.Preset = tok
|
|
presetTaken = true
|
|
default:
|
|
cfg.Allow = append(cfg.Allow, tok)
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// ParseToolSpec parses a "preset,+tool,-tool" spec into its parts. The
|
|
// first bare token is the preset; +name / -name are allow / deny deltas.
|
|
// Exported for CLI flag folding (cmd/gortex).
|
|
func ParseToolSpec(spec string) (preset string, allow, deny []string) {
|
|
cfg := parseToolSpec(spec)
|
|
return cfg.Preset, cfg.Allow, cfg.Deny
|
|
}
|
|
|
|
// mergeToolPolicyEnv overlays GORTEX_TOOLS / GORTEX_TOOLS_MODE over a
|
|
// base (config-file / flag-folded) config: an env preset or mode
|
|
// overrides the base when set; allow/deny deltas append. Mirrors the
|
|
// repo-wide "GORTEX_* env overrides file config" convention.
|
|
func mergeToolPolicyEnv(base ToolPolicyConfig) ToolPolicyConfig {
|
|
env, ok := toolPolicyConfigFromEnv()
|
|
if !ok {
|
|
return base
|
|
}
|
|
out := base
|
|
if env.Preset != "" {
|
|
out.Preset = env.Preset
|
|
}
|
|
if env.Mode != "" {
|
|
out.Mode = env.Mode
|
|
}
|
|
out.Allow = append(append([]string{}, base.Allow...), env.Allow...)
|
|
out.Deny = append(append([]string{}, base.Deny...), env.Deny...)
|
|
return out
|
|
}
|
|
|
|
// resolveToolPolicy builds the policy from a base config (threaded from
|
|
// options / the config file) with the GORTEX_TOOLS / GORTEX_TOOLS_MODE
|
|
// env overrides applied on top.
|
|
func resolveToolPolicy(base ToolPolicyConfig, logger *zap.Logger) *toolPolicy {
|
|
return newToolPolicy(mergeToolPolicyEnv(base), logger)
|
|
}
|
|
|
|
// ToolSurface is a resolved tool-visibility predicate usable outside the
|
|
// MCP server — the stdio proxy uses it to filter a daemon's tools/list
|
|
// and gate calls per connection, so a client can scope its own pipe
|
|
// (gortex mcp --tools / GORTEX_TOOLS) while the daemon stays full. Built
|
|
// from the same ToolPolicyConfig + GORTEX_TOOLS env as the server.
|
|
type ToolSurface struct{ p *toolPolicy }
|
|
|
|
// NewToolSurface resolves a config (with the GORTEX_TOOLS env overrides
|
|
// applied) into a queryable surface.
|
|
func NewToolSurface(base ToolPolicyConfig, logger *zap.Logger) *ToolSurface {
|
|
return &ToolSurface{p: resolveToolPolicy(base, logger)}
|
|
}
|
|
|
|
// Active reports whether the surface restricts anything at all.
|
|
func (s *ToolSurface) Active() bool { return s != nil && s.p.isActive() }
|
|
|
|
// Allows reports whether a tool name is visible in this surface. A nil
|
|
// or inactive surface allows everything.
|
|
func (s *ToolSurface) Allows(name string) bool {
|
|
if s == nil {
|
|
return true
|
|
}
|
|
return s.p.allows(name)
|
|
}
|
|
|
|
// GateCalls reports whether disallowed tools should be blocked at call
|
|
// time. True only for an active surface in hide mode; defer mode keeps
|
|
// non-listed tools reachable (the proxy analogue of the server keeping
|
|
// deferred tools callable after a tools_search promotion). A nil or
|
|
// inactive surface gates nothing.
|
|
func (s *ToolSurface) GateCalls() bool {
|
|
return s != nil && s.p.hideMode()
|
|
}
|
|
|
|
// Preset returns the resolved preset label (full / readonly / edit / nav
|
|
// / custom) for logging.
|
|
func (s *ToolSurface) Preset() string {
|
|
if s == nil || s.p == nil {
|
|
return "full"
|
|
}
|
|
return s.p.preset
|
|
}
|
|
|
|
// effectiveSessionPolicy resolves the tool-surface policy in force for
|
|
// the current request's session. Precedence: a client-forwarded preset /
|
|
// spec (GORTEX_TOOLS / --tools of the `gortex mcp` proxy, relayed through
|
|
// the daemon handshake) wins; else the client-aware preset default (a
|
|
// known coding-agent client gets the lean `agent` surface); else the
|
|
// server's global preset (the `core` default). The result is cached on the
|
|
// session so it is derived once, not on every tools/list. Never nil.
|
|
//
|
|
// This is the single authoritative resolution point the diet relies on:
|
|
// wherever tools/list is answered on the daemon, the surface for THIS
|
|
// connection is decided here, so a client preset actually applies instead
|
|
// of being a no-op the proxy can only subtract from.
|
|
func (s *Server) effectiveSessionPolicy(ctx context.Context) *toolPolicy {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
sess := s.sessionFor(ctx)
|
|
if sess == nil {
|
|
return s.toolPolicy
|
|
}
|
|
sess.mu.Lock()
|
|
if sess.toolPolicyResolved {
|
|
p := sess.resolvedToolPolicy
|
|
sess.mu.Unlock()
|
|
if p != nil {
|
|
return p
|
|
}
|
|
return s.toolPolicy
|
|
}
|
|
spec, mode, client := sess.toolSpec, sess.toolMode, sess.clientName
|
|
sess.mu.Unlock()
|
|
|
|
p := s.resolveSessionPolicy(spec, mode, client)
|
|
|
|
sess.mu.Lock()
|
|
sess.resolvedToolPolicy = p
|
|
sess.toolPolicyResolved = true
|
|
sess.mu.Unlock()
|
|
|
|
if p != nil {
|
|
return p
|
|
}
|
|
return s.toolPolicy
|
|
}
|
|
|
|
// resolveSessionPolicy builds the effective policy from a client-forwarded
|
|
// spec + mode and the client name, or returns nil to fall back to the
|
|
// server's global policy. A forwarded spec inherits the server's mode when
|
|
// the client did not pin one, so a bare `GORTEX_TOOLS=nav` keeps the
|
|
// daemon's defer semantics instead of silently switching to hide.
|
|
func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy {
|
|
if strings.TrimSpace(spec) != "" {
|
|
cfg := parseToolSpec(spec)
|
|
switch {
|
|
case strings.TrimSpace(mode) != "":
|
|
cfg.Mode = mode
|
|
case s.toolPolicy != nil:
|
|
cfg.Mode = s.toolPolicy.mode
|
|
}
|
|
return newToolPolicy(cfg, s.logger)
|
|
}
|
|
if p := s.instructionProfilePolicy(); p != nil {
|
|
return p
|
|
}
|
|
if p := s.clientDefaultPolicy(client); p != nil {
|
|
return p
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// activeInstructionPreset reads the machine's active instruction
|
|
// profile and returns its tool preset ("" when the profile keeps the
|
|
// defaults). Package var so tests can stub the machine state.
|
|
var activeInstructionPreset = profiles.ActiveToolPreset
|
|
|
|
// instructionProfilePolicy applies the active instruction profile's
|
|
// tool preset to sessions that forwarded no explicit spec. Precedence:
|
|
// a forwarded spec wins (checked by the caller before this), an
|
|
// operator-pinned mcp.tools / GORTEX_TOOLS configuration wins, then
|
|
// the profile, then the client-aware default. The default `core`
|
|
// profile carries no preset, so machines that never ran
|
|
// `gortex instructions switch` resolve exactly as before.
|
|
func (s *Server) instructionProfilePolicy() *toolPolicy {
|
|
if s == nil || s.toolPolicyOperatorPinned {
|
|
return nil
|
|
}
|
|
preset := activeInstructionPreset()
|
|
if strings.TrimSpace(preset) == "" {
|
|
return nil
|
|
}
|
|
mode := toolPolicyModeDefer
|
|
if s.toolPolicy != nil && s.toolPolicy.mode != "" {
|
|
mode = s.toolPolicy.mode
|
|
}
|
|
return newToolPolicy(ToolPolicyConfig{Preset: preset, Mode: mode}, s.logger)
|
|
}
|
|
|
|
// operatorPinnedToolPolicy reports whether the base tool-policy config
|
|
// expresses a deliberate operator choice rather than the shipped
|
|
// default (`core` preset in defer mode, no deltas) — the active
|
|
// instruction profile only refines the shipped default, never an
|
|
// operator pin. GORTEX_TOOLS / GORTEX_TOOLS_MODE always pin.
|
|
func operatorPinnedToolPolicy(base ToolPolicyConfig) bool {
|
|
if _, envSet := toolPolicyConfigFromEnv(); envSet {
|
|
return true
|
|
}
|
|
if len(base.Allow) > 0 || len(base.Deny) > 0 {
|
|
return true
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(base.Preset)) {
|
|
case "", "core", "default", "classic":
|
|
// The shipped default preset. A mode is only a pin when it
|
|
// deviates from the shipped defer default.
|
|
return strings.TrimSpace(base.Mode) != "" && normalizeToolMode(base.Mode) != toolPolicyModeDefer
|
|
}
|
|
return true
|
|
}
|
|
|
|
// clientDefaultPolicy returns the preset a known client should get when it
|
|
// forwarded no explicit tool spec, or nil to keep the server's global
|
|
// default. The default surface is client-aware: a known coding-agent client
|
|
// (the same set that defaults the wire format to GCX) gets the lean `agent`
|
|
// working set without any configuration; editors and unknown clients keep
|
|
// the server's global preset. GORTEX_TOOLS always overrides, because a
|
|
// forwarded spec is resolved before this in resolveSessionPolicy.
|
|
func (s *Server) clientDefaultPolicy(client string) *toolPolicy {
|
|
if !isKnownAgentClient(client) {
|
|
return nil
|
|
}
|
|
mode := toolPolicyModeDefer
|
|
if s.toolPolicy != nil && s.toolPolicy.mode != "" {
|
|
mode = s.toolPolicy.mode
|
|
}
|
|
return newToolPolicy(ToolPolicyConfig{Preset: "agent", Mode: mode}, s.logger)
|
|
}
|
|
|
|
// toolPolicyBaseFromOptions extracts the config-supplied tool policy
|
|
// from the MultiRepoOptions, or the zero config when none was provided
|
|
// (the GORTEX_TOOLS env override still applies in resolveToolPolicy).
|
|
func toolPolicyBaseFromOptions(opts []MultiRepoOptions) ToolPolicyConfig {
|
|
if len(opts) > 0 && opts[0].ToolPolicy != nil {
|
|
return *opts[0].ToolPolicy
|
|
}
|
|
return ToolPolicyConfig{}
|
|
}
|