diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index d1ae08a..efada76 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -29,7 +29,7 @@ func TestCommandsRegistered(t *testing.T) { registered[c.Name()] = true } - expected := []string{"server", "auth", "vault", "owner", "account", "catalog", "user", "agent", "ca"} + expected := []string{"server", "auth", "vault", "owner", "account", "catalog", "user", "agent", "ca", "logs", "inspect"} for _, name := range expected { if !registered[name] { t.Errorf("expected command %q to be registered, but it was not", name) @@ -37,6 +37,38 @@ func TestCommandsRegistered(t *testing.T) { } } +func TestInspectSubcommandsRegistered(t *testing.T) { + inspectCmd := findSubcommand(rootCmd, "inspect") + if inspectCmd == nil { + t.Fatal("inspect command not found") + } + + registered := make(map[string]bool) + for _, c := range inspectCmd.Commands() { + registered[c.Name()] = true + } + + expected := []string{"request", "explain"} + for _, name := range expected { + if !registered[name] { + t.Errorf("expected inspect subcommand %q to be registered, but it was not", name) + } + } +} + +func TestLogsFlagsRegistered(t *testing.T) { + logsCmd := findSubcommand(rootCmd, "logs") + if logsCmd == nil { + t.Fatal("logs command not found") + } + + for _, name := range []string{"vault", "ingress", "status", "service", "limit", "tail", "interval", "json"} { + if logsCmd.Flags().Lookup(name) == nil { + t.Errorf("expected logs flag --%s to be registered", name) + } + } +} + func TestCASubcommandsRegistered(t *testing.T) { caCmd := findSubcommand(rootCmd, "ca") if caCmd == nil { @@ -798,12 +830,12 @@ func TestResolveLogLevel(t *testing.T) { t.Setenv("AGENT_VAULT_LOG_LEVEL", "") cases := []struct { - name string - flag string - changed bool - env string - wantLevel string // "info" | "debug" - wantErr bool + name string + flag string + changed bool + env string + wantLevel string // "info" | "debug" + wantErr bool }{ {name: "default", flag: "info", changed: false, wantLevel: "info"}, {name: "flag_debug", flag: "debug", changed: true, wantLevel: "debug"}, diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 0000000..a1a0795 --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" + + "github.com/Infisical/agent-vault/internal/inspect" + "github.com/Infisical/agent-vault/internal/session" + "github.com/spf13/cobra" +) + +var inspectCmd = &cobra.Command{ + Use: "inspect", + Short: "Inspect request logs and explain likely proxy failures", +} + +var inspectRequestCmd = &cobra.Command{ + Use: "request --id ", + Short: "Inspect one safe request-log entry", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + id, _ := cmd.Flags().GetInt64("id") + if id <= 0 { + return fmt.Errorf("--id is required") + } + vault := resolveVault(cmd) + log, err := fetchLogByID(cmd, vault, id) + if err != nil { + return err + } + diagnosis := inspect.Diagnose(*log) + + jsonOut, _ := cmd.Flags().GetBool("json") + if jsonOut { + return printJSON(cmd, struct { + Log inspect.RequestLog `json:"log"` + Diagnosis inspect.Diagnosis `json:"diagnosis"` + }{Log: *log, Diagnosis: diagnosis}) + } + + renderRequestInspection(cmd, *log, diagnosis) + return nil + }, +} + +var inspectExplainCmd = &cobra.Command{ + Use: "explain", + Aliases: []string{"logs"}, + Short: "Explain likely failures in recent request logs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + vault := resolveVault(cmd) + resp, err := fetchLogs(cmd, vault, 0, 0) + if err != nil { + return err + } + items := inspect.DiagnoseBatch(resp.Logs) + jsonOut, _ := cmd.Flags().GetBool("json") + if jsonOut { + return printJSON(cmd, struct { + Diagnoses []inspect.DiagnosisForLog `json:"diagnoses"` + }{Diagnoses: items}) + } + renderExplain(cmd, items) + return nil + }, +} + +func fetchLogByID(cmd *cobra.Command, vault string, id int64) (*inspect.RequestLog, error) { + sess, err := ensureSession() + if err != nil { + return nil, err + } + + values := url.Values{} + values.Set("before", strconv.FormatInt(id+1, 10)) + values.Set("limit", "1") + path := fmt.Sprintf("/v1/vaults/%s/logs?%s", url.PathEscape(vault), values.Encode()) + + var respBody []byte + err = withReauthRetry(sess, sess.Address, func(s *session.ClientSession) error { + var ierr error + respBody, ierr = doAdminRequestWithBody("GET", s.Address+path, s.Token, nil) + return ierr + }) + if err != nil { + return nil, err + } + + var resp logsResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + if len(resp.Logs) == 0 || resp.Logs[0].ID != id { + return nil, fmt.Errorf("request log %d not found in vault %q", id, vault) + } + return &resp.Logs[0], nil +} + +func renderRequestInspection(cmd *cobra.Command, log inspect.RequestLog, diagnosis inspect.Diagnosis) { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Request %d\n\n", log.ID) + fmt.Fprintf(out, "Time: %s\n", formatLogTime(log.CreatedAt)) + fmt.Fprintf(out, "Ingress: %s\n", valueOrDash(log.Ingress)) + fmt.Fprintf(out, "Method: %s\n", valueOrDash(log.Method)) + fmt.Fprintf(out, "Host: %s\n", valueOrDash(log.Host)) + fmt.Fprintf(out, "Path: %s\n", valueOrDash(log.Path)) + fmt.Fprintf(out, "Status: %s\n", formatStatus(log)) + fmt.Fprintf(out, "Matched service: %s\n", valueOrDash(log.MatchedService)) + fmt.Fprintf(out, "Credential keys: %s\n", formatCredentialKeys(log.CredentialKeys)) + fmt.Fprintf(out, "Latency: %d ms\n", log.LatencyMs) + if log.ErrorCode != "" { + fmt.Fprintf(out, "Error code: %s\n", log.ErrorCode) + } + fmt.Fprintln(out) + renderDiagnosis(out, diagnosis) +} + +func renderExplain(cmd *cobra.Command, items []inspect.DiagnosisForLog) { + out := cmd.OutOrStdout() + if len(items) == 0 { + fmt.Fprintln(out, "No suspicious request logs found.") + return + } + for i, item := range items { + if i > 0 { + fmt.Fprintln(out) + } + fmt.Fprintf(out, "Request %d (%s %s%s -> %s)\n", item.Log.ID, valueOrDash(item.Log.Method), valueOrDash(item.Log.Host), valueOrDash(item.Log.Path), formatStatus(item.Log)) + renderDiagnosis(out, item.Diagnosis) + } +} + +func renderDiagnosis(out io.Writer, diagnosis inspect.Diagnosis) { + fmt.Fprintf(out, "Diagnosis:\n%s\n", diagnosis.Summary) + if len(diagnosis.Details) > 0 { + fmt.Fprintln(out, "\nDetails:") + for _, detail := range diagnosis.Details { + fmt.Fprintf(out, "- %s\n", detail) + } + } + if len(diagnosis.SuggestedNext) > 0 { + fmt.Fprintln(out, "\nSuggested next checks:") + for _, next := range diagnosis.SuggestedNext { + fmt.Fprintf(out, "- %s\n", next) + } + } +} + +func formatCredentialKeys(keys []string) string { + if len(keys) == 0 { + return "-" + } + safe := make([]string, 0, len(keys)) + for _, key := range keys { + safe = append(safe, valueOrDash(key)) + } + return strings.Join(safe, ", ") +} + +func init() { + inspectRequestCmd.Flags().String("vault", "", "vault name (default resolves from context)") + inspectRequestCmd.Flags().Int64("id", 0, "request log id") + inspectRequestCmd.Flags().Bool("json", false, "print JSON") + + inspectExplainCmd.Flags().String("vault", "", "vault name (default resolves from context)") + inspectExplainCmd.Flags().String("ingress", "", "filter by ingress: explicit or mitm") + inspectExplainCmd.Flags().String("status", "", "filter by status bucket: 2xx, 3xx, 4xx, 5xx, err") + inspectExplainCmd.Flags().String("service", "", "filter by matched service host") + inspectExplainCmd.Flags().Int("limit", 50, "number of recent logs to inspect (max 200)") + inspectExplainCmd.Flags().Bool("json", false, "print JSON") + + inspectCmd.AddCommand(inspectRequestCmd, inspectExplainCmd) + rootCmd.AddCommand(inspectCmd) +} diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 0000000..35dcf83 --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Infisical/agent-vault/internal/inspect" + "github.com/Infisical/agent-vault/internal/session" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" +) + +type logsResponse struct { + Logs []inspect.RequestLog `json:"logs"` + NextCursor *int64 `json:"next_cursor"` + LatestID int64 `json:"latest_id"` +} + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "List safe request logs for a vault", + Args: cobra.NoArgs, + RunE: runLogs, +} + +func runLogs(cmd *cobra.Command, args []string) error { + vault := resolveVault(cmd) + jsonOut, _ := cmd.Flags().GetBool("json") + tail, _ := cmd.Flags().GetBool("tail") + interval, _ := cmd.Flags().GetDuration("interval") + if tail && interval <= 0 { + return fmt.Errorf("--interval must be greater than 0") + } + + resp, err := fetchLogs(cmd, vault, 0, 0) + if err != nil { + return err + } + if jsonOut { + if err := printJSON(cmd, resp); err != nil { + return err + } + } else { + renderLogsTable(cmd, resp.Logs) + } + + if !tail { + return nil + } + after := resp.LatestID + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-cmd.Context().Done(): + return cmd.Context().Err() + case <-ticker.C: + resp, err := fetchLogs(cmd, vault, 0, after) + if err != nil { + return err + } + after = resp.LatestID + if len(resp.Logs) == 0 { + continue + } + if jsonOut { + if err := printJSON(cmd, resp); err != nil { + return err + } + continue + } + renderLogsTable(cmd, resp.Logs) + } + } +} + +func fetchLogs(cmd *cobra.Command, vault string, before, after int64) (*logsResponse, error) { + sess, err := ensureSession() + if err != nil { + return nil, err + } + + values := url.Values{} + limit, _ := cmd.Flags().GetInt("limit") + if limit > 0 { + values.Set("limit", strconv.Itoa(limit)) + } + if ingress, _ := cmd.Flags().GetString("ingress"); ingress != "" { + if err := validateIngress(ingress); err != nil { + return nil, err + } + values.Set("ingress", ingress) + } + if status, _ := cmd.Flags().GetString("status"); status != "" { + if err := validateStatusBucket(status); err != nil { + return nil, err + } + values.Set("status_bucket", status) + } + if service, _ := cmd.Flags().GetString("service"); service != "" { + values.Set("service", service) + } + if before > 0 { + values.Set("before", strconv.FormatInt(before, 10)) + } + if after > 0 { + values.Set("after", strconv.FormatInt(after, 10)) + } + + path := fmt.Sprintf("/v1/vaults/%s/logs", url.PathEscape(vault)) + if encoded := values.Encode(); encoded != "" { + path += "?" + encoded + } + + var respBody []byte + err = withReauthRetry(sess, sess.Address, func(s *session.ClientSession) error { + var ierr error + respBody, ierr = doAdminRequestWithBody("GET", s.Address+path, s.Token, nil) + return ierr + }) + if err != nil { + return nil, err + } + + var resp logsResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &resp, nil +} + +func renderLogsTable(cmd *cobra.Command, logs []inspect.RequestLog) { + if len(logs) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No request logs found.") + return + } + t := newTable(cmd.OutOrStdout()) + t.AppendHeader(table.Row{"ID", "TIME", "INGRESS", "METHOD", "HOST", "STATUS", "SERVICE", "LATENCY"}) + for _, log := range logs { + t.AppendRow(table.Row{ + log.ID, + formatLogTime(log.CreatedAt), + valueOrDash(log.Ingress), + valueOrDash(log.Method), + valueOrDash(log.Host), + formatStatus(log), + valueOrDash(log.MatchedService), + fmt.Sprintf("%d ms", log.LatencyMs), + }) + } + t.Render() +} + +func printJSON(cmd *cobra.Command, v any) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func formatLogTime(raw string) string { + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return raw + } + return t.Local().Format("2006-01-02 15:04:05") +} + +func formatStatus(log inspect.RequestLog) string { + if log.Status > 0 { + return strconv.Itoa(log.Status) + } + if log.ErrorCode != "" { + return "err:" + log.ErrorCode + } + return "err" +} + +func validateStatusBucket(s string) error { + switch s { + case "2xx", "3xx", "4xx", "5xx", "err": + return nil + default: + return fmt.Errorf("--status must be one of 2xx, 3xx, 4xx, 5xx, err") + } +} + +func validateIngress(s string) error { + switch s { + case "explicit", "mitm": + return nil + default: + return fmt.Errorf("--ingress must be one of explicit, mitm") + } +} + +func valueOrDash(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "-" + } + return stripControlChars(s) +} + +func stripControlChars(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return '?' + } + return r + }, s) +} + +func init() { + logsCmd.Flags().String("vault", "", "vault name (default resolves from context)") + logsCmd.Flags().String("ingress", "", "filter by ingress: explicit or mitm") + logsCmd.Flags().String("status", "", "filter by status bucket: 2xx, 3xx, 4xx, 5xx, err") + logsCmd.Flags().String("service", "", "filter by matched service host") + logsCmd.Flags().Int("limit", 50, "number of logs to fetch (max 200)") + logsCmd.Flags().Bool("tail", false, "poll for new request logs") + logsCmd.Flags().Duration("interval", 2*time.Second, "poll interval for --tail") + logsCmd.Flags().Bool("json", false, "print JSON") + rootCmd.AddCommand(logsCmd) +} diff --git a/cmd/logs_test.go b/cmd/logs_test.go new file mode 100644 index 0000000..8d9ad83 --- /dev/null +++ b/cmd/logs_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "testing" + + "github.com/Infisical/agent-vault/internal/inspect" +) + +func TestValidateStatusBucket(t *testing.T) { + for _, status := range []string{"2xx", "3xx", "4xx", "5xx", "err"} { + if err := validateStatusBucket(status); err != nil { + t.Fatalf("expected %q to be valid: %v", status, err) + } + } + if err := validateStatusBucket("401"); err == nil { + t.Fatal("expected exact status code to be rejected") + } +} + +func TestValidateIngress(t *testing.T) { + for _, ingress := range []string{"explicit", "mitm"} { + if err := validateIngress(ingress); err != nil { + t.Fatalf("expected %q to be valid: %v", ingress, err) + } + } + if err := validateIngress("http"); err == nil { + t.Fatal("expected unsupported ingress to be rejected") + } +} + +func TestFormatStatus(t *testing.T) { + if got := formatStatus(requestLogForTest(401, "")); got != "401" { + t.Fatalf("expected 401, got %q", got) + } + if got := formatStatus(requestLogForTest(0, "upstream_timeout")); got != "err:upstream_timeout" { + t.Fatalf("expected error code status, got %q", got) + } +} + +func TestValueOrDashStripsControlCharacters(t *testing.T) { + if got := valueOrDash("api.example.com/\x1b[31m"); got != "api.example.com/?[31m" { + t.Fatalf("expected control character to be replaced, got %q", got) + } +} + +func requestLogForTest(status int, errCode string) inspect.RequestLog { + return inspect.RequestLog{Status: status, ErrorCode: errCode} +} diff --git a/cmd/skill_cli.md b/cmd/skill_cli.md index e73ef9b..8eab0c5 100644 --- a/cmd/skill_cli.md +++ b/cmd/skill_cli.md @@ -190,7 +190,18 @@ Key fields (JSON mode): ## Request Logs -Agent Vault keeps a per-vault audit log of proxied requests (method, host, path, status, latency -- never bodies or query strings). The CLI does not wrap this yet; fetch via the HTTP API: `GET {AGENT_VAULT_ADDR}/v1/vaults/{vault}/logs` with `Authorization: Bearer {AGENT_VAULT_SESSION_TOKEN}`. Requires vault `member` or `admin` role. See `skill_http.md` for query params. +Agent Vault keeps a per-vault audit log of proxied requests (method, host, path, status, latency, matched service, credential key names -- never bodies, header values, query strings, or credential values). Requires vault `member` or `admin` role. + +Use the CLI for safe inspection: + +```bash +agent-vault logs --vault default +agent-vault logs --status 4xx --service api.anthropic.com +agent-vault inspect explain --vault default +agent-vault inspect request --id 123 --vault default +``` + +Use `--json` when another tool needs to parse the output. ## Building Code That Needs Credentials diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 81bfdb4..7777252 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -60,6 +60,69 @@ description: "Complete reference for all Agent Vault CLI commands." +## Request logs and inspection + + + + ```bash + agent-vault logs [flags] + ``` + + List the per-vault request log from the CLI. Request logs are safe metadata only: method, host, path, matched service, credential key names, status, latency, actor, ingress, and timestamp. Bodies, header values, query strings, and credential values are never returned. + + | Flag | Default | Description | + |------|---------|-------------| + | `--vault` | active vault | Vault name | + | `--ingress` | | Filter by ingress: `explicit` or `mitm` | + | `--status` | | Filter by status bucket: `2xx`, `3xx`, `4xx`, `5xx`, or `err` | + | `--service` | | Filter by matched service host | + | `--limit` | `50` | Number of logs to fetch, capped by the server at 200 | + | `--tail` | `false` | Poll for new logs | + | `--interval` | `2s` | Poll interval for `--tail` | + | `--json` | `false` | Print JSON | + + **Examples:** + + ```bash + agent-vault logs + agent-vault logs --status 4xx --service api.anthropic.com + agent-vault logs --tail + ``` + + + + ```bash + agent-vault inspect request --id [flags] + ``` + + Show one request log and a best-effort diagnosis derived from secret-free metadata. + + | Flag | Default | Description | + |------|---------|-------------| + | `--id` | | Request log id | + | `--vault` | active vault | Vault name | + | `--json` | `false` | Print JSON | + + + + ```bash + agent-vault inspect explain [flags] + agent-vault inspect logs [flags] + ``` + + Explain likely failure classes in recent request logs. `inspect logs` is an alias for `inspect explain`. The inspector is intentionally conservative: it never asks for bodies, header values, query strings, or credential values. + + | Flag | Default | Description | + |------|---------|-------------| + | `--vault` | active vault | Vault name | + | `--ingress` | | Filter by ingress: `explicit` or `mitm` | + | `--status` | | Filter by status bucket: `2xx`, `3xx`, `4xx`, `5xx`, or `err` | + | `--service` | | Filter by matched service host | + | `--limit` | `50` | Number of recent logs to inspect, capped by the server at 200 | + | `--json` | `false` | Print JSON | + + + ## CA diff --git a/internal/inspect/diagnostics.go b/internal/inspect/diagnostics.go new file mode 100644 index 0000000..092dbbd --- /dev/null +++ b/internal/inspect/diagnostics.go @@ -0,0 +1,203 @@ +package inspect + +import ( + "fmt" + "strings" +) + +// RequestLog is the secret-free request metadata needed for diagnostics. +type RequestLog struct { + ID int64 `json:"id"` + Ingress string `json:"ingress"` + Method string `json:"method"` + Host string `json:"host"` + Path string `json:"path"` + MatchedService string `json:"matched_service"` + CredentialKeys []string `json:"credential_keys"` + Status int `json:"status"` + LatencyMs int64 `json:"latency_ms"` + ErrorCode string `json:"error_code"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + CreatedAt string `json:"created_at"` +} + +// Diagnosis is a best-effort explanation derived only from safe metadata. +type Diagnosis struct { + Summary string `json:"summary"` + Details []string `json:"details,omitempty"` + SuggestedNext []string `json:"suggested_next,omitempty"` +} + +// Diagnose explains the most likely failure class for a single request log. +func Diagnose(log RequestLog) Diagnosis { + method := strings.ToUpper(strings.TrimSpace(log.Method)) + errCode := strings.ToLower(strings.TrimSpace(log.ErrorCode)) + + if log.Status == 405 && method != "CONNECT" && (log.Ingress == "mitm" || strings.Contains(errCode, "method")) { + return Diagnosis{ + Summary: "Plain HTTP traffic may be hitting Agent Vault's transparent HTTPS proxy listener.", + Details: []string{ + fmt.Sprintf("The request used %s and returned 405 on the %s ingress.", valueOrDash(method), valueOrDash(log.Ingress)), + "Agent Vault's transparent HTTPS proxy path expects CONNECT-based HTTPS traffic.", + }, + SuggestedNext: []string{ + "Use an HTTPS upstream endpoint if the provider or gateway supports one.", + "If this is a local AI gateway, check whether Agent Vault needs explicit HTTP proxy support for that integration.", + }, + } + } + + if log.MatchedService == "" { + if log.Status == 403 || log.Status == 404 { + return Diagnosis{ + Summary: "No configured service appears to match this request host.", + Details: []string{ + fmt.Sprintf("Host %q did not record a matched service.", log.Host), + "Agent Vault can only inject credentials after a vault service matches the request target.", + }, + SuggestedNext: []string{ + "Run `agent-vault vault service list` and verify the host pattern.", + "If the host is expected, add or enable a service for it.", + }, + } + } + return Diagnosis{ + Summary: "The request did not record a matched service.", + Details: []string{ + "Credential injection probably did not run for this request.", + }, + SuggestedNext: []string{ + "Verify the target host is configured in the active vault.", + }, + } + } + + if log.Status == 401 || log.Status == 403 { + next := []string{ + "Verify the referenced credential is current.", + "Confirm the service auth configuration matches the provider's expected header or URL format.", + } + if len(log.CredentialKeys) == 0 { + next = append(next, "If this service should inject credentials, check whether it is configured as passthrough.") + } else { + next = append(next, "Check provider-specific required headers such as version or beta headers.") + } + return Diagnosis{ + Summary: "The upstream service rejected the authenticated request.", + Details: []string{ + fmt.Sprintf("The request matched service %q and returned HTTP %d.", log.MatchedService, log.Status), + }, + SuggestedNext: next, + } + } + + if log.Status == 407 { + return Diagnosis{ + Summary: "Proxy authentication failed before the request reached the upstream service.", + Details: []string{ + "Agent Vault did not accept the proxy/session credentials for this request.", + }, + SuggestedNext: []string{ + "Relaunch the agent with `agent-vault run` or mint a fresh scoped session.", + "Verify HTTPS_PROXY and Proxy-Authorization are coming from the same Agent Vault instance.", + }, + } + } + + if log.Status >= 500 && log.Status <= 599 { + return Diagnosis{ + Summary: "The request failed at the upstream service or network boundary.", + Details: []string{ + fmt.Sprintf("The request matched service %q and returned HTTP %d.", log.MatchedService, log.Status), + }, + SuggestedNext: []string{ + "Retry against the provider directly from a trusted shell to separate provider outage from proxy configuration.", + "Check Agent Vault server debug logs for transport errors around the same timestamp.", + }, + } + } + + if log.Status == 0 || errCode != "" { + return Diagnosis{ + Summary: "The request failed before Agent Vault received a normal upstream HTTP response.", + Details: []string{ + fmt.Sprintf("Recorded error code: %s.", valueOrDash(log.ErrorCode)), + }, + SuggestedNext: []string{ + "Check TLS/CA trust configuration for the agent runtime.", + "Check DNS, sandbox egress rules, and local network access to the target host.", + }, + } + } + + if log.LatencyMs >= 10000 { + return Diagnosis{ + Summary: "The request succeeded or completed, but latency is high.", + Details: []string{ + fmt.Sprintf("Recorded latency was %d ms.", log.LatencyMs), + }, + SuggestedNext: []string{ + "Compare with direct provider latency from the same environment.", + "Check whether sandbox networking or the upstream provider is adding delay.", + }, + } + } + + if log.Status >= 200 && log.Status <= 399 { + return Diagnosis{ + Summary: "No obvious failure detected from request-log metadata.", + Details: []string{ + fmt.Sprintf("The request matched service %q and returned HTTP %d.", log.MatchedService, log.Status), + }, + SuggestedNext: []string{ + "If the agent still failed, inspect the agent-side application error because the proxy path appears healthy.", + }, + } + } + + return Diagnosis{ + Summary: "No specific diagnosis matched this request.", + Details: []string{ + "Agent Vault only records secret-free request metadata, so this explanation is intentionally conservative.", + }, + SuggestedNext: []string{ + "Use status, matched service, ingress, and server debug logs to narrow the failure.", + }, + } +} + +// DiagnoseBatch returns diagnoses for failed or suspicious logs. +func DiagnoseBatch(logs []RequestLog) []DiagnosisForLog { + out := make([]DiagnosisForLog, 0, len(logs)) + for _, log := range logs { + if !isSuspicious(log) { + continue + } + out = append(out, DiagnosisForLog{Log: log, Diagnosis: Diagnose(log)}) + } + return out +} + +// DiagnosisForLog pairs a log with its diagnosis. +type DiagnosisForLog struct { + Log RequestLog `json:"log"` + Diagnosis Diagnosis `json:"diagnosis"` +} + +func isSuspicious(log RequestLog) bool { + return log.Status == 0 || log.Status >= 400 || log.ErrorCode != "" || log.MatchedService == "" || log.LatencyMs >= 10000 +} + +func valueOrDash(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "-" + } + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return '?' + } + return r + }, s) +} diff --git a/internal/inspect/diagnostics_test.go b/internal/inspect/diagnostics_test.go new file mode 100644 index 0000000..03e2151 --- /dev/null +++ b/internal/inspect/diagnostics_test.go @@ -0,0 +1,51 @@ +package inspect + +import ( + "strings" + "testing" +) + +func TestDiagnosePlainHTTPOnMITM(t *testing.T) { + d := Diagnose(RequestLog{Ingress: "mitm", Method: "POST", Status: 405, ErrorCode: "method_not_supported"}) + if !strings.Contains(d.Summary, "Plain HTTP") { + t.Fatalf("expected plain HTTP diagnosis, got %q", d.Summary) + } +} + +func TestDiagnoseNoMatchedService(t *testing.T) { + d := Diagnose(RequestLog{Host: "api.example.com", Status: 403}) + if !strings.Contains(d.Summary, "No configured service") { + t.Fatalf("expected no-service diagnosis, got %q", d.Summary) + } +} + +func TestDiagnoseAuthRejected(t *testing.T) { + d := Diagnose(RequestLog{ + MatchedService: "api.anthropic.com", + CredentialKeys: []string{"ANTHROPIC_API_KEY"}, + Status: 401, + }) + if !strings.Contains(d.Summary, "rejected") { + t.Fatalf("expected auth rejection diagnosis, got %q", d.Summary) + } + if len(d.SuggestedNext) == 0 { + t.Fatal("expected suggestions") + } +} + +func TestDiagnoseBatchSkipsHealthyLogs(t *testing.T) { + got := DiagnoseBatch([]RequestLog{ + {ID: 1, MatchedService: "api.example.com", Status: 200}, + {ID: 2, MatchedService: "api.example.com", Status: 500}, + }) + if len(got) != 1 || got[0].Log.ID != 2 { + t.Fatalf("expected only failed log, got %+v", got) + } +} + +func TestDiagnoseStripsControlCharactersFromDetails(t *testing.T) { + d := Diagnose(RequestLog{Host: "api.example.com/\x1b[31m", Status: 403}) + if strings.Contains(d.Details[0], "\x1b") { + t.Fatalf("expected control character to be stripped, got %q", d.Details[0]) + } +}