Files
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

133 lines
4.1 KiB
Go

package hooks
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/platform"
)
// sessionState is the small per-session record the PreToolUse hook
// persists across individual tool calls. Claude Code invokes the hook
// as a fresh process per tool call, so any cross-call signal (has the
// agent consulted the graph yet? how long is the non-symbolic streak?)
// has to round-trip through disk keyed by session_id.
//
// Every field must be safe to read as its zero value — a missing or
// corrupt state file degrades to "fresh session", never an error.
type sessionState struct {
// GraphConsulted records that the agent has invoked at least one
// Gortex MCP tool this session. ModeConsultUnlock keys the
// deny→additionalContext downgrade on it.
GraphConsulted bool `json:"graph_consulted,omitempty"`
// NonSymbolicStreak counts consecutive non-symbolic fallback tool
// calls (Read / Grep / Glob) since the last symbolic call or nudge.
// ModeAdaptiveNudge fires a soft-deny when it crosses the threshold.
NonSymbolicStreak int `json:"non_symbolic_streak,omitempty"`
}
// hookSessionDirEnvVar lets tests redirect the per-session state
// directory, parallel to GORTEX_HOOK_LOG for telemetry.
const hookSessionDirEnvVar = "GORTEX_HOOK_SESSION_DIR"
// sessionStateDir returns the directory holding per-session state
// files. Honors GORTEX_HOOK_SESSION_DIR so tests can point it at a
// t.TempDir(). An absolute $XDG_CACHE_HOME is honoured; otherwise the
// directory stays under os.UserCacheDir() as before. Returns "" when no
// base directory can be resolved — all callers treat "" as "state
// disabled" and degrade gracefully.
func sessionStateDir() string {
if p := strings.TrimSpace(os.Getenv(hookSessionDirEnvVar)); p != "" {
return p
}
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if cacheDir, err := os.UserCacheDir(); err != nil || cacheDir == "" {
return ""
}
}
return filepath.Join(platform.OSCacheDir(), "sessions")
}
// sanitizeSessionID reduces an arbitrary session_id to a safe single
// path segment: only [A-Za-z0-9._-] survive, everything else becomes
// '_'. Guards against path traversal ("../") and separators in a
// session_id that originates from the untrusted hook payload.
func sanitizeSessionID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return ""
}
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '.' || r == '_' || r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
// A name that sanitizes to all dots ("." / "..") is still unsafe
// as a path segment — neutralize it.
if out == "" || strings.Trim(out, ".") == "" {
return ""
}
return out
}
// sessionStatePath returns the JSON file path for a session, or "" when
// the session ID is empty/unusable or no base directory is available.
func sessionStatePath(sessionID string) string {
safe := sanitizeSessionID(sessionID)
if safe == "" {
return ""
}
dir := sessionStateDir()
if dir == "" {
return ""
}
return filepath.Join(dir, safe+".json")
}
// loadSessionState reads the per-session record. Best-effort: an empty
// session ID, a missing file, or any read/decode error all yield a
// zero-value sessionState. The hook must never block on state I/O.
func loadSessionState(sessionID string) sessionState {
path := sessionStatePath(sessionID)
if path == "" {
return sessionState{}
}
data, err := os.ReadFile(path)
if err != nil {
return sessionState{}
}
var st sessionState
if err := json.Unmarshal(data, &st); err != nil {
return sessionState{}
}
return st
}
// saveSessionState writes the per-session record. Best-effort, mirroring
// telemetry.go: every error is swallowed so a read-only cache dir or a
// full disk can never stop a tool call from proceeding.
func saveSessionState(sessionID string, st sessionState) {
path := sessionStatePath(sessionID)
if path == "" {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
data, err := json.Marshal(st)
if err != nil {
return
}
_ = os.WriteFile(path, data, 0o644)
}