Skip to content
Draft
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
73 changes: 68 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,40 @@ nullify auth login --host api.acme.nullify.ai

Opens your browser for single sign-on. Tokens are stored locally and refreshed automatically.

Before it signs in, the CLI asks your instance where its login runs (`GET /auth/cli/config`). An instance attached to the Nullify router answers with the router's host (`api.nullify.ai`), and the CLI signs in and refreshes there; one login then covers every instance that shares it. An instance without that endpoint signs in on its own host. The CLI only follows an answer naming your instance or a Nullify router, and fails otherwise.

### Organizations

One login can cover several organizations on the same instance. Each command runs as the first of these that applies:

1. `--org <name|id>`
2. `NULLIFY_ORG` (name or id)
3. `NULLIFY_GITHUB_OWNER_ID`
4. The organization you picked with `nullify org use`, if your login still covers it
5. The owner of the current directory's git remote, if it names one of your organizations
6. Your only organization on the instance

When none applies, the CLI asks in a terminal and remembers the answer; elsewhere it fails and lists the choices.

```sh
# See your organizations on this instance and which one is in use
nullify org list

# Remember one for this instance
nullify org use acme-web

# Run one command as another
nullify findings --org acme-payments
```

An API token (`NULLIFY_TOKEN`) belongs to one organization, and the server uses it when the command names none. With a token, only `--org`, `NULLIFY_ORG` and `NULLIFY_GITHUB_OWNER_ID` apply (the first two are looked up on the instance itself); `nullify org use`, the git remote and the prompt are for browser logins.

### Environment Variables

```sh
export NULLIFY_HOST=api.acme.nullify.ai
export NULLIFY_TOKEN=your-token-here
export NULLIFY_ORG=acme-web # optional: organization to run as, by name or id
```

### Multiple Instances
Expand Down Expand Up @@ -133,10 +162,12 @@ nullify auth switch
|---------|-------------|
| `nullify auth login` | Authenticate via browser SSO |
| `nullify auth logout` | Clear stored credentials for a host |
| `nullify auth status` | Show auth state, host, and token expiry |
| `nullify auth status` | Show auth state, host, auth host, organization, and token expiry |
| `nullify auth token` | Print raw access token to stdout (pipe-friendly) |
| `nullify auth switch` | Switch active instance or list configured instances |
| `nullify auth config` | Print current CLI config as JSON |
| `nullify auth config` | Print the effective host, auth host, and organization as JSON |
| `nullify org list` | List the organizations you can use on this instance |
| `nullify org use <name\|id>` | Remember the organization to use on this instance |

### Scanning

Expand Down Expand Up @@ -274,6 +305,29 @@ Add the following to your MCP config file:
}
```

When your login covers several organizations, every tool takes an optional `org` argument (name or id), and the `list_organizations` tool lists them. Without the argument a call uses `--org`, then the same order as the CLI; if that still leaves several, the call returns the choices instead of guessing. To give each organization its own server, add one entry per organization:

```json
{
"mcpServers": {
"nullify-acme-web": {
"command": "nullify",
"args": ["mcp", "serve", "--org", "acme-web"]
},
"nullify-acme-payments": {
"command": "nullify",
"args": ["mcp", "serve", "--org", "acme-payments"]
}
}
}
```

With Claude Code:

```sh
claude mcp add nullify-acme-web -- nullify mcp serve --org acme-web
```

Scope findings to a specific repository with `--repo`:

```json
Expand All @@ -289,17 +343,26 @@ Scope findings to a specific repository with `--repo`:

## Configuration

The CLI stores configuration at `~/.nullify/config.json`. Host resolution priority:
The CLI stores configuration in `~/.nullify/`:

| File | Holds |
|------|-------|
| `config.json` | The active instance |
| `credentials.json` | Login tokens, one entry per login host (the router's login is shared by the instances that use it) |
| `hosts.json` | Per instance: the host its login runs on, and the organization picked with `nullify org use` |

Host resolution priority:

1. `--host` flag
2. Config file (`~/.nullify/config.json`)
3. `NULLIFY_HOST` environment variable
2. `NULLIFY_HOST` environment variable
3. Config file (`~/.nullify/config.json`)

### Global Flags

| Flag | Description | Default |
|------|-------------|---------|
| `--host` | Nullify API instance (e.g., `api.acme.nullify.ai`) | From config |
| `--org` | Organization to run as, by name or id (see [Organizations](#organizations)) | |
| `--output, -o` | Output format (`json`, `table`, `yaml`) | `json` |
| `--verbose, -v` | Enable verbose logging | `false` |
| `--debug, -d` | Enable debug logging | `false` |
Expand Down
83 changes: 68 additions & 15 deletions cmd/cli/cmd/auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -62,27 +63,49 @@ var loginCmd = &cobra.Command{
var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Log out of Nullify",
Long: "Clear stored credentials for the current or specified host.",
Long: "Clear stored credentials for the current or specified host. On an instance that signs in through the " +
"Nullify router, this removes the router login, which every instance signed in through it shares.",
RunE: func(cmd *cobra.Command, args []string) error {
logoutHost, err := resolveHostForAuth()
if err != nil {
return err
}

authHost, err := auth.AuthHostFor(logoutHost)
if err != nil {
return fmt.Errorf("logout failed: %w", err)
}
if err := auth.Logout(logoutHost); err != nil {
return fmt.Errorf("logout failed: %w", err)
}

fmt.Printf("Logged out from %s\n", logoutHost)
if auth.CredentialKey(authHost) != auth.CredentialKey(logoutHost) {
fmt.Printf("This removed the %s login, which other tenants signed in through it share.\n", authHost)
}
return nil
},
}

var statusCmd = &cobra.Command{
Use: "status",
Short: "Show authentication status",
Long: "Display the current authentication state including host, user, and token expiry.",
Long: "Display the current authentication state including host, auth host, organization, and token expiry.",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

if usesMachineToken() {
statusHost, err := resolveHostE(ctx)
if err != nil {
fmt.Println("No host configured. Pass --host or set NULLIFY_HOST.")
return nil
}
fmt.Printf("Host: %s\n", statusHost)
fmt.Println("Auth: API token")
fmt.Printf("Org: %s\n", describeOrg(ctx, statusHost))
return nil
}

cfg, err := auth.LoadConfig()
if err != nil {
fmt.Println("Not configured. Run 'nullify auth login --host <your-instance>.nullify.ai' to get started.")
Expand All @@ -96,14 +119,11 @@ var statusCmd = &cobra.Command{

fmt.Printf("Host: %s\n", cfg.Host)

creds, err := auth.LoadCredentials()
if err != nil {
fmt.Println("Status: not authenticated")
return nil
hostCreds, authHost, ok, err := auth.LookupCredentials(cfg.Host)
if authHost != "" {
fmt.Printf("Auth host: %s\n", authHost)
}

hostCreds, ok := creds[auth.CredentialKey(cfg.Host)]
if !ok {
if err != nil || !ok {
fmt.Println("Status: not authenticated")
return nil
}
Expand All @@ -118,6 +138,7 @@ var statusCmd = &cobra.Command{
} else {
fmt.Println("Status: authenticated")
}
fmt.Printf("Org: %s\n", describeOrg(ctx, cfg.Host))
return nil
},
}
Expand Down Expand Up @@ -156,15 +177,15 @@ var switchCmd = &cobra.Command{

if switchHost == "" {
// List available hosts
creds, err := auth.LoadCredentials()
if err != nil || len(creds) == 0 {
known, err := auth.KnownHosts()
if err != nil || len(known) == 0 {
fmt.Println("No configured hosts. Run 'nullify auth login --host <your-instance>.nullify.ai'")
return nil
}

cfg, _ := auth.LoadConfig()
fmt.Println("Configured hosts:")
for h := range creds {
for _, h := range known {
marker := " "
if cfg != nil && auth.CredentialKey(cfg.Host) == h {
marker = "* "
Expand Down Expand Up @@ -198,20 +219,52 @@ var switchCmd = &cobra.Command{
var configShowCmd = &cobra.Command{
Use: "config",
Short: "Show current configuration",
Long: "Display the current CLI configuration as JSON.",
Long: "Display the effective host, how it authenticates (login and its auth host, or API token), and the organization commands run as, as JSON.",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := auth.LoadConfig()
ctx := cmd.Context()
configHost, err := resolveHostE(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "No config found. Run 'nullify init' to set up.\n")
return nil
}

data, _ := json.MarshalIndent(cfg, "", " ")
shown := map[string]any{"host": configHost}
if usesMachineToken() {
shown["auth"] = "api_token"
} else {
shown["auth"] = "login"
if authHost, err := auth.AuthHostFor(configHost); err == nil {
shown["auth_host"] = authHost
}
}
shown["org"] = describeOrg(ctx, configHost)

data, _ := json.MarshalIndent(shown, "", " ")
fmt.Println(string(data))
return nil
},
}

// describeOrg says which org commands on host run as right now, without
// prompting.
func describeOrg(ctx context.Context, host string) string {
token, err := lib.GetNullifyToken(ctx, host, nullifyToken, githubToken)
if err != nil {
return "unknown (not authenticated)"
}
scope, err := resolveOrgScope(ctx, host, token, orgInputsFor(host, orgFlag, "--org"), false)
switch {
case err != nil:
return "not chosen: " + err.Error()
case scope.Org != nil:
return scope.Org.String()
case scope.Query["githubOwnerId"] != "":
return "githubOwnerId " + scope.Query["githubOwnerId"] + " (NULLIFY_GITHUB_OWNER_ID)"
default:
return "the API token's own (the server derives it)"
}
}

func init() {
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(loginCmd)
Expand Down
Loading