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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions cmd/riskkernel/doctor.go
Original file line number Diff line number Diff line change
@@ -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
}
106 changes: 106 additions & 0 deletions cmd/riskkernel/doctor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
3 changes: 3 additions & 0 deletions cmd/riskkernel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -90,6 +92,7 @@ Usage:
riskkernel approvals deny <id> [--reason ...] Deny a pending request
riskkernel memory list [namespace] List git-native memory entries
riskkernel memory show <name> [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
Expand Down