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
133 lines
4.1 KiB
Go
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)
|
|
}
|