feat: capture SSH exec commands (PLAN.md 4.4)

Bots often send commands via `ssh user@host <command>` (exec request)
rather than requesting an interactive shell. These were previously
rejected silently. Now exec commands are captured, stored on the session
record, and displayed in the web UI session detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 17:43:11 +01:00
parent 3c20e854aa
commit 0133d956a5
14 changed files with 206 additions and 10 deletions

View File

@@ -83,6 +83,16 @@ func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, sc
return nil
}
func (s *SQLiteStore) SetExecCommand(ctx context.Context, sessionID string, command string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE sessions SET exec_command = ? WHERE id = ?`,
command, sessionID)
if err != nil {
return fmt.Errorf("setting exec command: %w", err)
}
return nil
}
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, `
@@ -100,12 +110,13 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
var connectedAt string
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
var execCommand sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command
FROM sessions WHERE id = ?`, sessionID).Scan(
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
&connectedAt, &disconnectedAt, &humanScore,
&connectedAt, &disconnectedAt, &humanScore, &execCommand,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -122,6 +133,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio
if humanScore.Valid {
sess.HumanScore = &humanScore.Float64
}
if execCommand.Valid {
sess.ExecCommand = &execCommand.String
}
return &sess, nil
}
@@ -368,7 +382,7 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) (
}
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions`
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions`
if activeOnly {
query += ` WHERE disconnected_at IS NULL`
}
@@ -386,7 +400,8 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
var connectedAt string
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil {
var execCommand sql.NullString
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil {
return nil, fmt.Errorf("scanning session: %w", err)
}
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
@@ -397,6 +412,9 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
if humanScore.Valid {
s.HumanScore = &humanScore.Float64
}
if execCommand.Valid {
s.ExecCommand = &execCommand.String
}
sessions = append(sessions, s)
}
return sessions, rows.Err()