Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
377b62d
feat: add terminal spinner for long-running operations
raymondproguy Sep 1, 2026
753e098
feat: add box-drawing table output for query results
raymondproguy Sep 1, 2026
7d2b44b
feat: redact emails and IPs before sending data to AI providers
raymondproguy Sep 1, 2026
b608298
feat: add interactive ai config wizard with read-only DB role setup
raymondproguy Sep 1, 2026
3f152a4
feat: add ai ask command for direct natural-language questions
raymondproguy Sep 1, 2026
3f604ec
feat: add ai anomalies scan for credential-stuffing and abuse patterns
raymondproguy Sep 1, 2026
e43f93a
feat: add interactive oauth config wizard for provider credentials
raymondproguy Sep 1, 2026
83c663e
feat: add oauth providers add and polish oauth list/test output
raymondproguy Sep 1, 2026
bbb2fee
feat: add oauth users get and oauth unlink admin commands
raymondproguy Sep 1, 2026
c1c05b8
fix: make ai logs truly audit-only and stop trusting the model entity…
raymondproguy Sep 1, 2026
6cfa5f2
feat: add spinner and PII redaction to ai logs summarization
raymondproguy Sep 1, 2026
f008865
feat: show spinner while ai audit prioritizes findings
raymondproguy Sep 1, 2026
2a520ca
feat: add spinner and table output to ai query
raymondproguy Sep 1, 2026
7a118a0
fix: respect NO_COLOR for explicit plain-output opt-out
raymondproguy Sep 1, 2026
5732f79
fix: report dev version and correct target cryden version
raymondproguy Sep 1, 2026
bc6fa3b
feat: add doctor command aggregating all health checks
raymondproguy Sep 1, 2026
32a42a0
feat: wire new ai/oauth/doctor commands into the CLI
raymondproguy Sep 1, 2026
2db7ef8
docs: document new ai/oauth/doctor commands and privacy behavior
raymondproguy Sep 1, 2026
2e3c0ba
chore: clean up trailing newlines in gitignore and migration
raymondproguy Sep 1, 2026
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
.env
csax
csax
102 changes: 64 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,12 @@

Admin CLI for CrydenSync — manage users, sessions, and audit logs from the terminal. Not end-user facing; this is for developers/operators running a CrydenSync-backed app, same as `psql` is for a database, not for the app's own users.

## Installation
## Install

```bash
go install github.com/crydensync/csax@latest
```

If you get `command not found` (or, on Windows, `'csax' is not recognized`) after this, `csax` installed correctly — it's just not on your `PATH` yet. Fix:

**Linux / macOS / Termux:**
```bash
echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc # use ~/.zshrc if that's your shell
source ~/.bashrc
```

**Windows (PowerShell):**
```powershell
setx PATH "$env:Path;$(go env GOPATH)\bin"
```
Then open a new terminal window for it to take effect.

Confirm it worked:
```bash
csax version
```

## Setup

```bash
Expand All @@ -48,35 +29,57 @@ 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 audit ask "<question>" # alias for `ai ask` — natural-language question over your own data
csax oauth providers list [--json] | add <provider> # add: interactive, configures one provider's credentials
csax oauth test <provider> # round-trips the provider's real endpoints before a live user hits it
csax oauth users get <email> [--json] # which providers an account has linked
csax oauth unlink <email> --provider <name> # force-unlink a provider from an account
csax oauth config # interactive: BASE_URL/FRONTEND_URL + both providers at once
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 anomalies scan [--since 24h] # fixed-prompt `ai logs`, looks for credential-stuffing/abuse patterns
csax ai ask "<question>" # direct question over any allowlisted entity, answered in plain language
csax ai audit # flags likely misconfigurations from a fixed checklist — never auto-applies anything
csax ai config # interactive: provider/model/API key + auto-creates the read-only DB role
csax doctor # one-shot health check: DB, migrations, AI config, OAuth config
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`
`oauth providers list`/`add`/`test` all 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
BASE_URL, FRONTEND_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.
Run `csax oauth config` for an interactive setup of all of the above,
or `csax oauth providers add <provider>` to add just one provider once
`BASE_URL`/`FRONTEND_URL` are already set.

**`BASE_URL` vs `FRONTEND_URL` — a mixup that actually happened during
testing:** `BASE_URL` is your BACKEND's own public URL, since that's
where the `/api/oauth/.../callback` route lives. If your frontend and
backend are on different domains (e.g. Vercel + Railway), `BASE_URL`
is the Railway one, not the Vercel one. Both wizards spell this out
explicitly rather than assuming it's obvious.

`oauth users get`/`oauth unlink` manage which providers a specific
user has linked. These don't call any `cryden` engine method — they
query `oauth_identities` directly via SQL, same pattern as `users
list`/`stats` below. No engine change was needed or made to support
them.

## Optional: AI-assisted admin commands

`ai query`/`ai logs`/`ai audit` need their own config, on top of the
usual `.env`:
Run `csax ai config` for an interactive setup — it prompts for the
provider/model/API-key-env-name, and can create the read-only
database role for you directly (see below), rather than handing you a
script to run by hand. Or set these in `.env` yourself:

```
AI_PROVIDER=groq # or "openrouter" — both speak the same OpenAI-compatible chat completions shape
Expand All @@ -85,28 +88,51 @@ 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.
`READONLY_DATABASE_URL` is the real safety boundary for `ai
query`/`ai logs`/`ai anomalies scan`/`ai ask` — 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.

`csax ai config`'s role setup works against ANY Postgres provider, not
just Supabase — it only appends Supabase's `<role>.<project-ref>`
username suffix when it actually detects a Supabase pooler host; a
self-hosted Postgres, Neon, RDS, etc. all just get the plain role
name.

`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.
`ai audit` never applies a fix, `ai logs`/`ai anomalies scan` never
revoke a session or lock an account they flag. Anything like that is a
suggestion in the output text, run yourself as a separate, explicit
command.

**Privacy note on `ai ask`/`ai logs`/`ai anomalies scan`:** emails and
IPs are redacted to stable per-query placeholders (`email_1`, `ip_2`)
before anything is sent to your configured AI provider for
summarization — patterns like repetition and clustering are still
visible to the model, but real values never leave your infrastructure
through that path. The full, unredacted data is still what gets
printed to your own terminal afterward. This is a default `csax`
ships with, not something separately audited — worth knowing if
you're evaluating this for a deployment with stricter data-handling
requirements.

## 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.
- 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`, `oauth users get`, `oauth unlink`) — 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. 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.
- Colored, box-drawing table output by default (`table.go`) — auto-disabled when not writing to a real terminal, or when `NO_COLOR` is set (see https://no-color.org). Long values (UUIDs, emails) are truncated with `…` rather than blowing up column widths. Audit event types get semantic color: red for anything `*_failed`/`*_reuse_detected`/`*_locked`, green for `*_success`/`*_linked`/`*_unlocked`.
- A terminal spinner (`spinner.go`) shows during any real network/DB round trip — AI provider calls, the OAuth endpoint reachability check, and the read-only role's connection test. Skipped automatically in the same non-TTY/`NO_COLOR` cases as the table output, so scripted/piped usage never sees spinner frames mixed into captured output.
- `csax doctor` aggregates the existing `csax health` and `csax ai audit` checks plus an OAuth config summary into one command — it calls the same underlying functions those commands use rather than re-implementing the checks a second time.
- **Known gap, not addressed this release:** `--json` is only implemented on a handful of commands (`users get`, `users list`, `oauth providers list`, `oauth users get`, `ai query`). Retrofitting it consistently across every command (`ai logs`, `ai ask`, `ai anomalies scan`, `ai audit`, `doctor`, `stats`, `audit tail`/`search`) is real, not-yet-done work — each needs its own JSON-serializable shape, not just a flag.
- **Known gap, not addressed this release:** this is still one flat `package main` across ~20 files. Splitting into proper subpackages (`internal/oauth`, `internal/ai`, `internal/config`, ...) is a real structural change touching nearly every file's imports at once — deliberately NOT attempted alongside this release's feature work, since it's a much higher-risk change to make at the same time as everything else here, and much harder to review as one large diff. Worth doing as its own dedicated pass, with nothing else changing in that same commit.

## License

Expand Down
67 changes: 67 additions & 0 deletions cmd_ai_anomalies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"context"
"fmt"
"os"

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

// cmdAIAnomaliesScan is `ai logs` pre-run with a fixed, no-input
// prompt — deliberately implemented as a thin wrapper around the same
// auditOnlyProvider/ExecuteQuery path rather than a separate code
// path, so the two commands can never quietly drift apart in
// behavior.
func cmdAIAnomaliesScan(cfg csaxConfig, since string) {
readonlyDB, provider := mustAISetup(cfg)
defer readonlyDB.Close()

store := postgres.NewSafeQueryStore(readonlyDB)
auditProvider := &auditOnlyProvider{inner: provider}

naturalLanguage := fmt.Sprintf(
"unusual or suspicious activity in the last %s — repeated failed logins, token reuse detections, logins from new or unusual locations, or clusters of signups from the same source",
since,
)
var result ai.QueryResult
err := withSpinner("Scanning for anomalies...", func() error {
var qerr error
result, qerr = ai.ExecuteQuery(context.Background(), store, auditProvider, naturalLanguage)
return qerr
})
if err != nil {
fmt.Println(red("Anomaly scan failed: " + err.Error()))
os.Exit(1)
}

if len(result.Rows) == 0 {
fmt.Println(dim("No events found in that window."))
return
}

var summary string
summarizeErr := withSpinner("Summarizing...", func() error {
var serr error
summary, serr = provider.Summarize(context.Background(),
"You are scanning audit log events for a system administrator, looking specifically for signs of "+
"credential stuffing, account takeover attempts, or abuse. Some identifiers below are placeholders "+
"(email_1, ip_2, etc.) standing in for real values — refer to them exactly as given, never invent a "+
"real-looking email or IP. Summarize what you actually see in 2-4 plain-language sentences. Only "+
"describe patterns present in the data given — never invent detail. If nothing looks concerning, "+
"say so plainly rather than manufacturing a finding.",
redactForSummary(result))
return serr
})
if summarizeErr != nil {
fmt.Println(yellow("(could not generate a summary: " + summarizeErr.Error() + ")"))
} else {
fmt.Println(summary)
fmt.Println()
}

fmt.Println(dim("Run `csax ai logs \"...\"` for detail on any of these, or `csax ai query` for user-level data."))
fmt.Println()
printQueryResult(result, false)
}
66 changes: 66 additions & 0 deletions cmd_ai_ask.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"context"
"errors"
"fmt"
"os"

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

// cmdAIAsk implements `csax ai ask "<question>"` (aliased as `csax
// audit ask`). Unlike `ai logs`, this does NOT force Entity to
// audit_events — a real operator question ("anything weird with
// devray@example.com this week?") may need to look at users or
// sessions too, not just the audit trail. Same ExecuteQuery/
// SafeQueryStore path as `ai query`, phrased as a direct question
// with a narrative answer instead of a raw table.
func cmdAIAsk(cfg csaxConfig, question string) {
readonlyDB, provider := mustAISetup(cfg)
defer readonlyDB.Close()

store := postgres.NewSafeQueryStore(readonlyDB)
var result ai.QueryResult
err := withSpinner("Thinking...", func() error {
var qerr error
result, qerr = ai.ExecuteQuery(context.Background(), store, provider, question)
return qerr
})
if err != nil {
if errors.Is(err, ai.ErrUnsafeQueryIntent) {
fmt.Println(red("That question can't be translated into an allowed query."))
fmt.Println(dim("Try rephrasing, or ask about one of: users, sessions, audit_events."))
os.Exit(1)
}
fmt.Println(red("Couldn't answer that: " + err.Error()))
os.Exit(1)
}

if len(result.Rows) == 0 {
fmt.Println(dim("No matching data found."))
return
}

var answer string
answerErr := withSpinner("Summarizing...", func() error {
var aerr error
answer, aerr = provider.Summarize(context.Background(),
"You are answering a system administrator's direct question about their own application's data. "+
"Some identifiers below are placeholders (email_1, ip_2, etc.) standing in for real values — refer "+
"to them exactly as given, never invent a real-looking email or IP. Answer the question directly in "+
"2-4 plain-language sentences, using only what's in the data given. If the data doesn't actually "+
"answer the question, say so rather than guessing.",
question+"\n\nData:\n"+redactForSummary(result))
return aerr
})
if answerErr != nil {
fmt.Println(yellow("(could not generate an answer: " + answerErr.Error() + ")"))
} else {
fmt.Println(answer)
fmt.Println()
}

printQueryResult(result, false)
}
17 changes: 11 additions & 6 deletions cmd_ai_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,17 @@ func cmdAIAudit(cfg csaxConfig) {
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 != "" {
var narrative string
summarizeErr := withSpinner("Summarizing...", func() error {
var serr error
narrative, serr = 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))
return serr
})
if summarizeErr == nil && narrative != "" {
fmt.Println("\n" + narrative)
}
}
Expand Down
Loading
Loading