feat: add human detection scoring and webhook notifications

Implement phase 2.1 (human detection) and 2.2 (notifications):

- Detection scorer computes 0.0-1.0 human likelihood from keystroke
  timing variance, special key usage, typing speed, command diversity,
  and session duration
- Webhook notifier sends JSON POST to configured endpoints with
  deduplication, custom headers, and event filtering
- RecordingChannel gains an event callback for feeding keystrokes
  to the scorer without coupling shell and detection packages
- Server wires scorer into session lifecycle with periodic updates
  and threshold-based notification triggers
- Web UI shows human score in session tables with highlighting
- New config sections: [detection] and [[notify.webhooks]]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 21:28:11 +01:00
parent 96c8476f77
commit 0ad6f4cb6a
13 changed files with 1060 additions and 32 deletions

View File

@@ -9,13 +9,15 @@ import (
)
type Config struct {
SSH SSHConfig `toml:"ssh"`
Auth AuthConfig `toml:"auth"`
Storage StorageConfig `toml:"storage"`
Shell ShellConfig `toml:"shell"`
Web WebConfig `toml:"web"`
LogLevel string `toml:"log_level"`
LogFormat string `toml:"log_format"` // "text" (default) or "json"
SSH SSHConfig `toml:"ssh"`
Auth AuthConfig `toml:"auth"`
Storage StorageConfig `toml:"storage"`
Shell ShellConfig `toml:"shell"`
Web WebConfig `toml:"web"`
Detection DetectionConfig `toml:"detection"`
Notify NotifyConfig `toml:"notify"`
LogLevel string `toml:"log_level"`
LogFormat string `toml:"log_format"` // "text" (default) or "json"
}
type WebConfig struct {
@@ -59,6 +61,25 @@ type Credential struct {
Password string `toml:"password"`
}
type DetectionConfig struct {
Enabled bool `toml:"enabled"`
Threshold float64 `toml:"threshold"`
UpdateInterval string `toml:"update_interval"`
// Parsed duration, not from TOML directly.
UpdateIntervalDuration time.Duration `toml:"-"`
}
type NotifyConfig struct {
Webhooks []WebhookNotifyConfig `toml:"webhooks"`
}
type WebhookNotifyConfig struct {
URL string `toml:"url"`
Headers map[string]string `toml:"headers"`
Events []string `toml:"events"` // empty = all events
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -127,6 +148,12 @@ func applyDefaults(cfg *Config) {
if cfg.Shell.Banner == "" {
cfg.Shell.Banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n"
}
if cfg.Detection.Threshold == 0 {
cfg.Detection.Threshold = 0.6
}
if cfg.Detection.UpdateInterval == "" {
cfg.Detection.UpdateInterval = "5s"
}
}
// knownShellKeys are top-level keys in [shell] that are not per-shell sub-tables.
@@ -189,5 +216,33 @@ func validate(cfg *Config) error {
}
}
// Validate detection config.
if cfg.Detection.Enabled {
if cfg.Detection.Threshold < 0 || cfg.Detection.Threshold > 1 {
return fmt.Errorf("detection.threshold must be between 0 and 1, got %f", cfg.Detection.Threshold)
}
ui, err := time.ParseDuration(cfg.Detection.UpdateInterval)
if err != nil {
return fmt.Errorf("invalid detection.update_interval %q: %w", cfg.Detection.UpdateInterval, err)
}
if ui <= 0 {
return fmt.Errorf("detection.update_interval must be positive, got %s", ui)
}
cfg.Detection.UpdateIntervalDuration = ui
}
// Validate notify config.
knownEvents := map[string]bool{"human_detected": true, "session_started": true}
for i, wh := range cfg.Notify.Webhooks {
if wh.URL == "" {
return fmt.Errorf("notify.webhooks[%d]: url must not be empty", i)
}
for j, ev := range wh.Events {
if !knownEvents[ev] {
return fmt.Errorf("notify.webhooks[%d].events[%d]: unknown event %q", i, j, ev)
}
}
}
return nil
}