From 52f868c40acffe45703e7f44a43e96fb9b057eb6 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sat, 13 Jun 2026 18:12:33 +0530 Subject: [PATCH] =?UTF-8?q?feat(cli):=20riskkernel=20doctor=20=E2=80=94=20?= =?UTF-8?q?diagnose=20a=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `riskkernel doctor` command that runs a checklist over a setup and prints ✓/⚠/✗ for each, exiting non-zero on a hard failure (CI-friendly): - data dir is creatable + writable (the file the user owns must persist), - the default provider is known and has its credential (Ollama is key-free, Bedrock is flagged as a stub), - the default budget isn't explicitly unlimited (a reliability runtime shouldn't run unbounded), - the API token is set (warns when the API is unauthenticated), - a configured riskkernel.yaml parses (a bad one would fail daemon startup), - the daemon is reachable on the configured port (info/warn — useful either way). The config + filesystem checks are split into a pure diagnose() so they're unit tested (provider matrix, budget states, token, policy-file valid/invalid/missing, data-dir writable/uncreatable). Reduces time-to-first-value: a new user can see exactly what's missing before "why doesn't it work?". --- CHANGELOG.md | 5 + cmd/riskkernel/doctor.go | 171 ++++++++++++++++++++++++++++++++++ cmd/riskkernel/doctor_test.go | 106 +++++++++++++++++++++ cmd/riskkernel/main.go | 3 + 4 files changed, 285 insertions(+) create mode 100644 cmd/riskkernel/doctor.go create mode 100644 cmd/riskkernel/doctor_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a4dff18..31a3340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] ### Added +- **`riskkernel doctor`.** Diagnose a setup before relying on it: a checklist over + the data dir (creatable/writable), the default provider and its credential, the + default budget (flags an explicitly-unlimited one), the API token, a configured + `riskkernel.yaml` (validated), and whether the daemon is reachable. Exits non-zero + on a hard failure so it's CI-friendly. - **Per-run policy enforcement.** A run created under a policy bundle (`policyRef`) is now governed by that bundle, not just its budget: the MCP gateway enforces the bundle's tool **allowlist** for that run (a tool outside it is blocked even if the diff --git a/cmd/riskkernel/doctor.go b/cmd/riskkernel/doctor.go new file mode 100644 index 0000000..6385de1 --- /dev/null +++ b/cmd/riskkernel/doctor.go @@ -0,0 +1,171 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/prashar32/riskkernel/internal/config" + "github.com/prashar32/riskkernel/internal/policy" +) + +// diagnosis is one check result. Status is "ok", "warn", or "fail". +type diagnosis struct { + Name string + Status string + Detail string +} + +const ( + diagOK = "ok" + diagWarn = "warn" + diagFail = "fail" +) + +// runDoctor implements `riskkernel doctor` — diagnose a setup before you rely on +// it. Prints a checklist and exits non-zero if any hard check fails. +func runDoctor(_ []string) error { + cfg, err := config.Load() + if err != nil { + fmt.Println("✗ config — could not load configuration:", err) + return fmt.Errorf("configuration failed to load") + } + + results := diagnose(cfg) + results = append(results, checkDaemon(cfg)) // live probe (info/warn only) + + failed := 0 + for _, d := range results { + fmt.Printf("%s %s%s\n", diagSymbol(d.Status), d.Name, detailSuffix(d.Detail)) + if d.Status == diagFail { + failed++ + } + } + fmt.Println() + if failed > 0 { + return fmt.Errorf("%d check(s) failed", failed) + } + fmt.Println("setup looks good.") + return nil +} + +// diagnose runs the deterministic config + filesystem checks. Kept side-effect +// light (only a data-dir write probe) and dependency-free so it is unit-testable. +func diagnose(cfg *config.Config) []diagnosis { + return []diagnosis{ + checkDataDir(cfg), + checkProvider(cfg), + checkBudget(cfg), + checkAPIToken(cfg), + checkPolicyFile(cfg), + } +} + +// checkDataDir verifies the state directory is creatable and writable — the file +// the user owns must be persistable. +func checkDataDir(cfg *config.Config) diagnosis { + d := diagnosis{Name: "data dir (" + cfg.DataDir + ")"} + if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { + return diagnosis{d.Name, diagFail, "not creatable: " + err.Error()} + } + probe := filepath.Join(cfg.DataDir, ".doctor-write-probe") + if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil { + return diagnosis{d.Name, diagFail, "not writable: " + err.Error()} + } + _ = os.Remove(probe) + return diagnosis{d.Name, diagOK, "writable"} +} + +// checkProvider verifies the default provider is known and has the credential it +// needs (Ollama is key-free; Bedrock is a stub). +func checkProvider(cfg *config.Config) diagnosis { + name := "default provider (" + cfg.DefaultProvider + ")" + switch cfg.DefaultProvider { + case "anthropic": + if cfg.AnthropicAPIKey == "" { + return diagnosis{name, diagWarn, "ANTHROPIC_API_KEY not set — model calls will fail"} + } + return diagnosis{name, diagOK, "ANTHROPIC_API_KEY set"} + case "openai": + if cfg.OpenAIAPIKey == "" { + return diagnosis{name, diagWarn, "OPENAI_API_KEY not set — model calls will fail"} + } + return diagnosis{name, diagOK, "OPENAI_API_KEY set"} + case "ollama": + return diagnosis{name, diagOK, "key-free (local); ensure Ollama is running"} + case "bedrock": + return diagnosis{name, diagWarn, "bedrock is a stub — model calls return not-implemented"} + default: + return diagnosis{name, diagFail, "unknown provider"} + } +} + +// checkBudget flags a fully-unlimited explicit default budget — a reliability +// runtime should not run unbounded by default. +func checkBudget(cfg *config.Config) diagnosis { + b := cfg.DefaultBudget + if b.Defaulted { + return diagnosis{"default budget", diagOK, "safe defaults applied (no RISKKERNEL_DEFAULT_* set)"} + } + if b.Tokens == 0 && b.Dollars == 0 && b.Loops == 0 && b.Seconds == 0 { + return diagnosis{"default budget", diagWarn, "explicitly unlimited — runs have no default ceiling"} + } + return diagnosis{"default budget", diagOK, fmt.Sprintf("$%.2f / %d loops / %d tokens / %ds", b.Dollars, b.Loops, b.Tokens, b.Seconds)} +} + +// checkAPIToken warns when the API is unauthenticated. +func checkAPIToken(cfg *config.Config) diagnosis { + if cfg.APIToken == "" { + return diagnosis{"api token", diagWarn, "RISKKERNEL_API_TOKEN not set — the API is unauthenticated; don't expose this port"} + } + return diagnosis{"api token", diagOK, "set"} +} + +// checkPolicyFile validates a configured riskkernel.yaml; a bad one would fail +// daemon startup. +func checkPolicyFile(cfg *config.Config) diagnosis { + if cfg.PolicyFile == "" { + return diagnosis{"policy file", diagOK, "none configured (RISKKERNEL_POLICY_FILE unset)"} + } + f, err := policy.Load(cfg.PolicyFile) + if err != nil { + return diagnosis{"policy file (" + cfg.PolicyFile + ")", diagFail, err.Error()} + } + return diagnosis{"policy file (" + cfg.PolicyFile + ")", diagOK, fmt.Sprintf("valid — %d bundle(s)", len(f.Policies))} +} + +// checkDaemon probes a running daemon's /healthz. Info/warn only — the doctor is +// useful whether or not the daemon is up. +func checkDaemon(cfg *config.Config) diagnosis { + name := fmt.Sprintf("daemon (:%d)", cfg.Port) + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/healthz", cfg.Port)) + if err != nil { + return diagnosis{name, diagWarn, "not reachable (start it with `riskkernel serve`)"} + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return diagnosis{name, diagOK, "responding on /healthz"} + } + return diagnosis{name, diagWarn, fmt.Sprintf("unexpected /healthz status %d", resp.StatusCode)} +} + +func diagSymbol(status string) string { + switch status { + case diagOK: + return "✓" + case diagWarn: + return "⚠" + default: + return "✗" + } +} + +func detailSuffix(detail string) string { + if detail == "" { + return "" + } + return " — " + detail +} diff --git a/cmd/riskkernel/doctor_test.go b/cmd/riskkernel/doctor_test.go new file mode 100644 index 0000000..d57ed78 --- /dev/null +++ b/cmd/riskkernel/doctor_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prashar32/riskkernel/internal/config" +) + +func TestCheckProvider(t *testing.T) { + cases := []struct { + provider, anthropicKey, openaiKey, want string + }{ + {"anthropic", "k", "", diagOK}, + {"anthropic", "", "", diagWarn}, + {"openai", "", "k", diagOK}, + {"openai", "", "", diagWarn}, + {"ollama", "", "", diagOK}, + {"bedrock", "", "", diagWarn}, + {"martian", "", "", diagFail}, + } + for _, c := range cases { + got := checkProvider(&config.Config{ + DefaultProvider: c.provider, AnthropicAPIKey: c.anthropicKey, OpenAIAPIKey: c.openaiKey, + }) + if got.Status != c.want { + t.Errorf("provider %q (anthropic=%q openai=%q): status = %q, want %q", c.provider, c.anthropicKey, c.openaiKey, got.Status, c.want) + } + } +} + +func TestCheckBudget(t *testing.T) { + if got := checkBudget(&config.Config{DefaultBudget: config.BudgetConfig{Defaulted: true}}); got.Status != diagOK { + t.Errorf("safe defaults: %q", got.Status) + } + if got := checkBudget(&config.Config{DefaultBudget: config.BudgetConfig{}}); got.Status != diagWarn { + t.Errorf("explicit unlimited should warn: %q", got.Status) + } + if got := checkBudget(&config.Config{DefaultBudget: config.BudgetConfig{Dollars: 5}}); got.Status != diagOK { + t.Errorf("bounded budget: %q", got.Status) + } +} + +func TestCheckAPIToken(t *testing.T) { + if got := checkAPIToken(&config.Config{}); got.Status != diagWarn { + t.Errorf("no token should warn: %q", got.Status) + } + if got := checkAPIToken(&config.Config{APIToken: "t"}); got.Status != diagOK { + t.Errorf("token set: %q", got.Status) + } +} + +func TestCheckPolicyFile(t *testing.T) { + if got := checkPolicyFile(&config.Config{}); got.Status != diagOK { + t.Errorf("no policy file should be ok: %q", got.Status) + } + + dir := t.TempDir() + valid := filepath.Join(dir, "ok.yaml") + _ = os.WriteFile(valid, []byte("schemaVersion: 1\npolicies:\n - name: dev\n"), 0o600) + if got := checkPolicyFile(&config.Config{PolicyFile: valid}); got.Status != diagOK { + t.Errorf("valid policy file: %q (%s)", got.Status, got.Detail) + } + + bad := filepath.Join(dir, "bad.yaml") + _ = os.WriteFile(bad, []byte("schemaVersion: 2\npolicies: []\n"), 0o600) + if got := checkPolicyFile(&config.Config{PolicyFile: bad}); got.Status != diagFail { + t.Errorf("invalid policy file should fail: %q", got.Status) + } + if got := checkPolicyFile(&config.Config{PolicyFile: filepath.Join(dir, "missing.yaml")}); got.Status != diagFail { + t.Errorf("missing policy file should fail: %q", got.Status) + } +} + +func TestCheckDataDir(t *testing.T) { + if got := checkDataDir(&config.Config{DataDir: t.TempDir()}); got.Status != diagOK { + t.Errorf("writable temp dir: %q (%s)", got.Status, got.Detail) + } + // A path whose parent is a file can't be created as a directory. + f := filepath.Join(t.TempDir(), "afile") + _ = os.WriteFile(f, []byte("x"), 0o600) + if got := checkDataDir(&config.Config{DataDir: filepath.Join(f, "sub")}); got.Status != diagFail { + t.Errorf("uncreatable data dir should fail: %q", got.Status) + } +} + +func TestDiagnose_CoversChecks(t *testing.T) { + cfg := &config.Config{ + DataDir: t.TempDir(), DefaultProvider: "ollama", + DefaultBudget: config.BudgetConfig{Defaulted: true}, + } + got := diagnose(cfg) + if len(got) != 5 { + t.Fatalf("expected 5 diagnoses, got %d", len(got)) + } + for _, d := range got { + if d.Status == diagFail { + t.Errorf("unexpected fail for a healthy config: %s — %s", d.Name, d.Detail) + } + if !strings.ContainsAny(d.Status, "okwarnfail") { + t.Errorf("bad status %q", d.Status) + } + } +} diff --git a/cmd/riskkernel/main.go b/cmd/riskkernel/main.go index 64d3847..40aee29 100644 --- a/cmd/riskkernel/main.go +++ b/cmd/riskkernel/main.go @@ -53,6 +53,8 @@ func main() { err = runApprovals(args) case "memory": err = runMemory(args) + case "doctor": + err = runDoctor(args) case "healthcheck": err = runHealthcheck(args) case "version", "--version", "-v": @@ -90,6 +92,7 @@ Usage: riskkernel approvals deny [--reason ...] Deny a pending request riskkernel memory list [namespace] List git-native memory entries riskkernel memory show [namespace] Print a memory file + riskkernel doctor Diagnose a setup (config, store, provider, policy) riskkernel healthcheck Probe /healthz (used by the Docker HEALTHCHECK) riskkernel version Print build identity riskkernel help Show this help