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
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,4 @@ jobs:
./csax users get ci-test@example.com
./csax users list
./csax stats
./csax health
./csax health
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ jobs:
generate_release_notes: true
name: Release ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,64 @@ csax sessions revoke <session-id> --user <email>
csax sessions revoke-all --user <email>
csax audit tail --user <email> [--limit N]
csax audit search --event <type> [--limit N] # system-wide, across all users
csax oauth providers list [--json] # which providers have client ID/secret set
csax oauth test <provider> # round-trips the provider's real endpoints before a live user hits it
csax ai query "<natural language>" [--json] # read-only, allowlisted lookups over users/sessions/audit_events
csax ai logs "<natural language>" # natural-language audit event search, summarized — never acts
csax ai audit # flags likely misconfigurations from a fixed checklist — never auto-applies anything
csax stats # total users, active sessions, etc.
csax health
csax version
```

## Optional: OAuth admin commands

`oauth providers list` and `oauth test` read the SAME env vars `api`
uses for its own OAuth config — they check the real values production
uses, not a separate csax-only copy:

```
BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
```

`oauth users get`/`oauth unlink` (managing which providers a specific
user has linked) are designed but not built yet — they need two new
`cryden` engine facades that don't exist as of this CLI's current
`cryden` dependency version.

## Optional: AI-assisted admin commands

`ai query`/`ai logs`/`ai audit` need their own config, on top of the
usual `.env`:

```
AI_PROVIDER=groq # or "openrouter" — both speak the same OpenAI-compatible chat completions shape
AI_API_KEY_ENV=GROQ_API_KEY # the NAME of the env var holding your key — the key itself lives wherever that env var is set, never in .env
AI_MODEL=... # any model id your chosen provider serves
READONLY_DATABASE_URL=... # a SEPARATE connection string, pointing at a Postgres role that is physically read-only
```

`READONLY_DATABASE_URL` is the real safety boundary for `ai query`/`ai
logs` — even a bug in the underlying allowlist validation can't cause
a write if the credential itself is incapable of one. Don't point it
at the same role `DATABASE_URL` uses.

`ai audit` works even without any AI config — the checklist itself is
plain Go, not model-generated; the model is only used to add a short
prioritization narrative on top, which is skipped silently if AI isn't
configured.

None of the `ai` commands ever execute an action a finding surfaces —
`ai audit` never applies a fix, and `ai logs` never revokes a session
or locks an account it flags. Anything like that is a suggestion in
the output text, run yourself as a separate, explicit command.

## Design notes

- Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI.
- `MIGRATIONS_DIR` (default `./migrations`) should point at a folder containing both CrydenSync's own migration files and your app's own — `csax migrate` treats them the same, just files matching `*.up.sql`/`*.down.sql`, run in filename order.
- No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself.
- No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself. This is also why `oauth`/`ai` help text lives in `usage()` by hand rather than being generated — there's no framework here to generate it from.
- `ai` commands are the one part of csax that calls out to something other than this deployment's own Postgres — the `ai.LLMProvider` interface ships zero implementations upstream in `cryden`; csax brings its own (`llmProvider` in `aiprovider.go`, an OpenAI-chat-completions-shaped client — works with Groq, OpenRouter, or any other provider speaking that same shape via `AI_PROVIDER`), same pattern as `notify.EmailSender`.
- Colored output by default (auto-disabled when not writing to a real terminal). Commands returning structured data (`users get`, `users list`, `sessions list`) support `--json` for scripting.

## License
Expand Down
174 changes: 174 additions & 0 deletions aiprovider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

"github.com/crydensync/cryden/v2/ai"
)

// llmProvider is csax's ai.LLMProvider implementation. It's a thin
// OpenAI-chat-completions-shaped client — Groq, OpenRouter, and most
// other hosted providers all speak this same request/response shape,
// so supporting another one is a base-URL entry here, not a new
// client. ai.LLMProvider ships zero implementations upstream in
// cryden by design — this is the consumer bringing its own provider
// and key, same pattern as notify.EmailSender and logger.Logger.
type llmProvider struct {
baseURL string
apiKey string
model string
}

// chatCompletionsBaseURL maps AI_PROVIDER to its endpoint. Adding a
// new OpenAI-compatible provider is one line here.
var chatCompletionsBaseURL = map[string]string{
"groq": "https://api.groq.com/openai/v1/chat/completions",
"openrouter": "https://openrouter.ai/api/v1/chat/completions",
}

func newLLMProvider(cfg csaxConfig) (*llmProvider, error) {
if cfg.AIProvider == "" {
return nil, fmt.Errorf("AI_PROVIDER is not set (expected one of: groq, openrouter)")
}
baseURL, ok := chatCompletionsBaseURL[cfg.AIProvider]
if !ok {
known := make([]string, 0, len(chatCompletionsBaseURL))
for k := range chatCompletionsBaseURL {
known = append(known, k)
}
return nil, fmt.Errorf("unknown AI_PROVIDER %q (expected one of: %s)", cfg.AIProvider, strings.Join(known, ", "))
}
if cfg.AIAPIKeyEnv == "" {
return nil, fmt.Errorf("AI_API_KEY_ENV is not set — point it at the env var holding your %s API key", cfg.AIProvider)
}
apiKey := os.Getenv(cfg.AIAPIKeyEnv)
if apiKey == "" {
return nil, fmt.Errorf("env var %s (named by AI_API_KEY_ENV) is not set or empty", cfg.AIAPIKeyEnv)
}
model := cfg.AIModel
if model == "" {
return nil, fmt.Errorf("AI_MODEL is not set")
}
return &llmProvider{baseURL: baseURL, apiKey: apiKey, model: model}, nil
}

// ParseQueryIntent asks the model to translate natural language into
// JSON matching ai.QueryIntent's shape. The model's raw output is
// still just text at this point — untrusted — and gets validated
// against the real allowlist by ai.ExecuteQuery AFTER this returns,
// never trusted just because it parsed as valid JSON.
func (p *llmProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) {
systemPrompt := strings.TrimSpace(`
You translate an admin's natural-language request into a JSON object with this exact shape:
{"entity": "users|sessions|audit_events", "filters": [{"field": "...", "operator": "=|>|<|contains", "value": "..."}], "aggregate": "|count|group_by", "group_by": "", "limit": 0}
Only use these fields per entity:
- users: id, email, failed_attempts, locked_until, created_at
- sessions: id, user_id, ip, user_agent, created_at, revoked_at
- audit_events: id, type, user_id, ip, created_at
Never invent a field or entity outside these lists. Reply with ONLY the JSON object, no other text, no markdown fences.
`)

raw, err := p.chatCompletion(ctx, systemPrompt, naturalLanguage)
if err != nil {
return ai.QueryIntent{}, err
}

var parsed struct {
Entity string `json:"entity"`
Filters []ai.QueryFilter `json:"filters"`
Aggregate string `json:"aggregate"`
GroupBy string `json:"group_by"`
Limit int `json:"limit"`
}
if err := json.Unmarshal([]byte(stripCodeFence(raw)), &parsed); err != nil {
return ai.QueryIntent{}, fmt.Errorf("model did not return valid JSON: %w", err)
}
return ai.QueryIntent{
Entity: parsed.Entity,
Filters: parsed.Filters,
Aggregate: parsed.Aggregate,
GroupBy: parsed.GroupBy,
Limit: parsed.Limit,
}, nil
}

// Summarize is used by `csax ai logs` and `csax ai audit` — plain
// text in, plain text out, no QueryIntent structure needed since
// those commands aren't building a database query from the model's
// output, just asking it to narrate something csax already fetched
// itself.
func (p *llmProvider) Summarize(ctx context.Context, systemPrompt, userContent string) (string, error) {
return p.chatCompletion(ctx, systemPrompt, userContent)
}

func (p *llmProvider) chatCompletion(ctx context.Context, systemPrompt, userContent string) (string, error) {
reqBody := map[string]any{
"model": p.model,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userContent},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("could not reach the AI provider: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("AI provider request failed: status %d: %s", resp.StatusCode, string(respBody))
}

var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", err
}
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("OpenRouter returned no choices")
}
return parsed.Choices[0].Message.Content, nil
}

// stripCodeFence handles the common case of a model wrapping its
// JSON in ```json ... ``` despite being told not to — defensive, not
// load-bearing: ai.validateIntent still runs on whatever this
// produces, so a model that ignores instructions in some OTHER way
// still can't produce an unsafe query, just an error.
func stripCodeFence(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}
102 changes: 102 additions & 0 deletions cmd_ai_audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"context"
"fmt"
)

// auditFinding is produced entirely by plain Go logic below — the
// model never decides what counts as a finding, it only explains and
// prioritizes findings that already exist. This is the deliberate
// split from the design: "the checklist itself is fixed Go code, not
// model-generated."
type auditFinding struct {
Severity string // "HIGH", "MEDIUM", "OK"
Message string
}

// runAuditChecklist is pure, deterministic, and needs no AI provider
// to run — worth being able to run standalone later if `csax ai
// audit` ever gets a non-AI sibling command.
func runAuditChecklist(cfg csaxConfig) []auditFinding {
var findings []auditFinding

if cfg.JWTSecret == "" {
findings = append(findings, auditFinding{"HIGH", "JWT_SECRET is not set."})
} else if len(cfg.JWTSecret) < 32 {
findings = append(findings, auditFinding{"HIGH", fmt.Sprintf("JWT secret is %d characters — recommend 32+ for HS256.", len(cfg.JWTSecret))})
} else {
findings = append(findings, auditFinding{"OK", "JWT secret length looks reasonable."})
}

if cfg.ReadOnlyDBURL == "" {
findings = append(findings, auditFinding{"MEDIUM", "READONLY_DATABASE_URL is not set — ai query/logs are unavailable until it is."})
} else if cfg.ReadOnlyDBURL == cfg.DatabaseURL {
findings = append(findings, auditFinding{"HIGH", "READONLY_DATABASE_URL is identical to DATABASE_URL — this must point at a genuinely read-only role, not the same writable connection."})
} else {
findings = append(findings, auditFinding{"OK", "A separate read-only database connection is configured."})
}

if cfg.BaseURL == "" {
findings = append(findings, auditFinding{"MEDIUM", "BASE_URL is not set — OAuth callback URLs cannot be computed."})
} else {
findings = append(findings, auditFinding{"OK", "BASE_URL is set."})
}

oauthConfigured := (cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "") || (cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "")
if !oauthConfigured {
findings = append(findings, auditFinding{"OK", "No OAuth providers configured — nothing to check there yet."})
}

return findings
}

// cmdAIAudit implements `csax ai audit`. No --fix flag, deliberately
// — per the standing constraint, if one is ever added it must show a
// diff and require per-change confirmation, never a batch apply.
// This command only ever prints; it changes nothing.
func cmdAIAudit(cfg csaxConfig) {
findings := runAuditChecklist(cfg)

for _, f := range findings {
icon := "✓"
color := green
if f.Severity == "HIGH" {
icon, color = "⚠", red
} else if f.Severity == "MEDIUM" {
icon, color = "⚠", yellow
}
fmt.Printf("%s %-6s %s\n", color(icon), f.Severity, f.Message)
}

flagged := 0
for _, f := range findings {
if f.Severity != "OK" {
flagged++
}
}
fmt.Printf("\n%d check(s), %d flagged. No changes made.\n", len(findings), flagged)

// The narrative layer is optional — audit still works, and still
// changes nothing, even if AI isn't configured at all.
provider, err := newLLMProvider(cfg)
if err != nil || flagged == 0 {
return
}
narrative, err := provider.Summarize(context.Background(),
"You are prioritizing a fixed list of security configuration findings for a system administrator. "+
"You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+
"which flagged item to fix first and why, in plain language.",
formatFindingsForSummary(findings))
if err == nil && narrative != "" {
fmt.Println("\n" + narrative)
}
}

func formatFindingsForSummary(findings []auditFinding) string {
out := ""
for _, f := range findings {
out += f.Severity + ": " + f.Message + "\n"
}
return out
}
Loading
Loading