Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .roborev.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
[review_guidelines]

threat_model = """
review_guidelines = """
agentsview is a LOCAL-ONLY developer tool. It binds to 127.0.0.1
by default and is not designed for multi-user or public deployment.

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/components/insights/InsightsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@
<span class="task-phase">{task.phase}</span>
{/if}
</div>
<span class="task-agent">{task.agent}</span>
<button
class="task-dismiss"
onclick={() => task.status === "error"
Expand Down Expand Up @@ -802,6 +803,15 @@
text-overflow: ellipsis;
}

.task-agent {
flex-shrink: 0;
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: -0.02em;
white-space: nowrap;
}

.task-dismiss {
flex-shrink: 0;
width: 18px;
Expand Down
61 changes: 61 additions & 0 deletions internal/insight/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
Expand All @@ -25,6 +26,12 @@ var ValidAgents = map[string]bool{
"gemini": true,
}

// GenerateFunc is the signature for insight generation,
// allowing tests to substitute a stub.
type GenerateFunc func(
ctx context.Context, agent, prompt string,
) (Result, error)

// Generate invokes an AI agent CLI to generate an insight.
// The agent parameter selects which CLI to use (claude,
// codex, gemini). The prompt is passed via stdin.
Expand Down Expand Up @@ -54,6 +61,60 @@ func Generate(
}
}

// allowedKeyPrefixes lists uppercase key prefixes that are
// safe to pass to agent CLI subprocesses. Matched
// case-insensitively so Windows-style casing (Path, ComSpec)
// is handled correctly. Using an allowlist prevents leaking
// secrets to child processes.
var allowedKeyPrefixes = []string{
"PATH",
"HOME", "USERPROFILE",
"USER", "USERNAME", "LOGNAME",
"LANG", "LC_",
"TERM", "COLORTERM",
"TMPDIR", "TEMP", "TMP",
"XDG_",
"SHELL",
"SSL_CERT_", "CURL_CA_BUNDLE",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
"HOMEDRIVE", "HOMEPATH",
"APPDATA", "LOCALAPPDATA", "PROGRAMDATA",
}

// envKeyAllowed reports whether key (case-insensitive) is
// on the allowlist. Prefix entries ending with _ (LC_,
// XDG_, SSL_CERT_) match any key starting with that prefix;
// all others require an exact match.
func envKeyAllowed(key string) bool {
upper := strings.ToUpper(key)
for _, p := range allowedKeyPrefixes {
if strings.HasSuffix(p, "_") {
if strings.HasPrefix(upper, p) {
return true
}
} else if upper == p {
return true
}
}
return false
}

// cleanEnv returns an allowlisted subset of the current
// environment for agent CLI subprocesses, plus
// CLAUDE_NO_SOUND=1.
func cleanEnv() []string {
env := os.Environ()
filtered := make([]string, 0, len(env))
for _, e := range env {
k, _, _ := strings.Cut(e, "=")
if envKeyAllowed(k) {
filtered = append(filtered, e)
}
}
return append(filtered, "CLAUDE_NO_SOUND=1")
}

// generateClaude invokes `claude -p --output-format json`.
func generateClaude(
ctx context.Context, path, prompt string,
Expand Down
87 changes: 87 additions & 0 deletions internal/insight/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,93 @@ func TestParseStreamJSON_Empty(t *testing.T) {
}
}

func TestCleanEnv(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-secret")
t.Setenv("CLAUDECODE", "1")
t.Setenv("HOME", "/home/test")
t.Setenv("AWS_SECRET_ACCESS_KEY", "s3cret")
t.Setenv("PATH", "/usr/bin")
t.Setenv("LANG", "en_US.UTF-8")
t.Setenv("UNKNOWN_VAR", "should-be-dropped")

env := cleanEnv()

// Normalize keys to uppercase for cross-platform assertions
// (Windows may return Path instead of PATH).
envMap := make(map[string]string, len(env))
for _, e := range env {
k, v, _ := strings.Cut(e, "=")
envMap[strings.ToUpper(k)] = v
}

// Secrets and unknown vars must not pass through.
for _, blocked := range []string{
"ANTHROPIC_API_KEY", "CLAUDECODE",
"AWS_SECRET_ACCESS_KEY", "UNKNOWN_VAR",
} {
if _, ok := envMap[blocked]; ok {
t.Errorf("%s should not be in env", blocked)
}
}

// Allowed system vars must pass through.
for _, allowed := range []string{
"HOME", "PATH", "LANG",
} {
if _, ok := envMap[allowed]; !ok {
t.Errorf("%s should be preserved", allowed)
}
}

if v, ok := envMap["CLAUDE_NO_SOUND"]; !ok || v != "1" {
t.Errorf(
"CLAUDE_NO_SOUND should be 1, got %q", v,
)
}
}

func TestEnvKeyAllowed(t *testing.T) {
tests := []struct {
key string
want bool
}{
{"PATH", true},
{"Path", true}, // Windows-style
{"path", true}, // lowercase
{"HOME", true},
{"Home", true},
{"COMSPEC", true},
{"ComSpec", true}, // Windows-style
{"LC_ALL", true}, // prefix match
{"XDG_CONFIG_HOME", true},
{"SSL_CERT_FILE", true},
{"HTTP_PROXY", true},
{"APPDATA", true},
{"AppData", true},
{"LOCALAPPDATA", true},
{"PROGRAMDATA", true},
{"PATHEXT", true},
{"PathExt", true},
{"WINDIR", true},
{"HOMEDRIVE", true},
{"HOMEPATH", true},
{"ANTHROPIC_API_KEY", false},
{"AWS_SECRET_ACCESS_KEY", false},
{"DATABASE_URL", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := envKeyAllowed(tt.key); got != tt.want {
t.Errorf(
"envKeyAllowed(%q) = %v, want %v",
tt.key, got, tt.want,
)
}
})
}
}

func TestValidAgents(t *testing.T) {
for _, agent := range []string{
"claude", "codex", "gemini",
Expand Down
4 changes: 2 additions & 2 deletions internal/server/deadline_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ func TestHandlers_Internal_DeadlineExceeded(t *testing.T) {
s := testServer(t, 30*time.Second)

// Seed a session just in case handlers check for existence before context.
// We'll use the public methods on db to seed.
started := "2025-01-15T10:00:00Z"
sess := db.Session{
ID: "s1",
Expand Down Expand Up @@ -52,7 +51,8 @@ func TestHandlers_Internal_DeadlineExceeded(t *testing.T) {

w := httptest.NewRecorder()

// Call handler directly, bypassing middleware
// Call handler directly, bypassing middleware.
// handleContextError writes 504 for deadline exceeded.
tt.handler(w, req)

assertRecorderStatus(t, w, http.StatusGatewayTimeout)
Expand Down
4 changes: 1 addition & 3 deletions internal/server/deadline_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package server_test

import (
"net/http"
"net/http/httptest"
"testing"
)
Expand Down Expand Up @@ -35,8 +34,7 @@ func TestMiddleware_Timeout(t *testing.T) {
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)

assertTimeoutResponse(t, w,
http.StatusServiceUnavailable, "request timed out")
assertTimeoutRace(t, w)
})
}
}
2 changes: 1 addition & 1 deletion internal/server/insights.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (s *Server) handleGenerateInsight(
)
defer cancel()

result, err := insight.Generate(
result, err := s.generateFunc(
genCtx, req.Agent, prompt,
)
if err != nil {
Expand Down
15 changes: 14 additions & 1 deletion internal/server/insights_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package server_test

import (
"context"
"fmt"
"net/http"
"testing"

"github.com/wesm/agentsview/internal/db"
"github.com/wesm/agentsview/internal/insight"
"github.com/wesm/agentsview/internal/server"
)

func TestListInsights_Empty(t *testing.T) {
Expand Down Expand Up @@ -181,7 +184,17 @@ func TestGenerateInsight_InvalidAgent(t *testing.T) {
}

func TestGenerateInsight_DefaultAgent(t *testing.T) {
te := setup(t)
stubGen := func(
_ context.Context, agent, _ string,
) (insight.Result, error) {
if agent != "claude" {
t.Errorf("expected default agent claude, got %q", agent)
}
return insight.Result{}, fmt.Errorf("stub: no CLI")
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateFunc(stubGen),
})

w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
Expand Down
13 changes: 9 additions & 4 deletions internal/server/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,20 @@ func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

// handleContextError handles context.Canceled and context.DeadlineExceeded
// errors by writing the appropriate HTTP response. Returns true if the
// error was handled (i.e., it was a context error), false otherwise.
// handleContextError checks for context.Canceled and
// context.DeadlineExceeded. On cancellation it returns true
// silently (client disconnected). On deadline exceeded it
// writes a 504 and returns true. Behind withTimeout the 504
// goes into the TimeoutHandler buffer and is discarded if
// the middleware fires first.
func handleContextError(w http.ResponseWriter, err error) bool {
if errors.Is(err, context.Canceled) {
return true
}
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "gateway timeout")
writeError(
w, http.StatusGatewayTimeout, "gateway timeout",
)
return true
}
return false
Expand Down
8 changes: 1 addition & 7 deletions internal/server/search.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package server

import (
"context"
"errors"
"net/http"
"strings"

Expand Down Expand Up @@ -63,11 +61,7 @@ func (s *Server) handleSearch(

page, err := s.db.Search(r.Context(), filter)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "search timed out")
if handleContextError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, err.Error())
Expand Down
29 changes: 21 additions & 8 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/wesm/agentsview/internal/config"
"github.com/wesm/agentsview/internal/db"
"github.com/wesm/agentsview/internal/insight"
"github.com/wesm/agentsview/internal/sync"
"github.com/wesm/agentsview/internal/web"
)
Expand All @@ -35,8 +36,9 @@ type Server struct {
httpSrv *http.Server
version VersionInfo

spaFS fs.FS
spaHandler http.Handler
generateFunc insight.GenerateFunc
spaFS fs.FS
spaHandler http.Handler
}

// New creates a new Server.
Expand All @@ -50,12 +52,13 @@ func New(
}

s := &Server{
cfg: cfg,
db: database,
engine: engine,
mux: http.NewServeMux(),
spaFS: dist,
spaHandler: http.FileServerFS(dist),
cfg: cfg,
db: database,
engine: engine,
mux: http.NewServeMux(),
generateFunc: insight.Generate,
spaFS: dist,
spaHandler: http.FileServerFS(dist),
}
for _, opt := range opts {
opt(s)
Expand All @@ -72,6 +75,16 @@ func WithVersion(v VersionInfo) Option {
return func(s *Server) { s.version = v }
}

// WithGenerateFunc overrides the insight generation function,
// allowing tests to substitute a stub. Nil is ignored.
func WithGenerateFunc(f insight.GenerateFunc) Option {
return func(s *Server) {
if f != nil {
s.generateFunc = f
}
}
}

func (s *Server) routes() {
// API v1 routes
s.mux.Handle("GET /api/v1/sessions", s.withTimeout(s.handleListSessions))
Expand Down
Loading