Skip to content
Open
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
16 changes: 16 additions & 0 deletions app/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ b) [Attestation Crafting Process](https://docs.chainloop.dev/getting-started/att

The project is a [Go](https://go.dev/) CLI that leverages [Cobra](https://github.com/spf13/cobra) for CLI scaffolding, [Viper](https://github.com/spf13/viper) for configuration handling, [gRPC](https://grpc.io/) to communicate with both the control plane and the Artifact CAS APIs, and the [cosign](https://github.com/sigstore/cosign), [in-toto](https://github.com/in-toto/in-toto), [DSEE](https://github.com/secure-systems-lab/dsse/) and [SLSA](https://github.com/slsa-framework/slsa) projects for the attestation process.

## OrcaRouter Cyber Inference

`chainloop orcarouter` configures [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible inference gateway for security teams, so `chainloop trace run --codex` and the Trace end-to-end suite can route a Codex session through `https://api.orcarouter.ai/v1` with `model_providers.orcarouter`. The provider is `OrcaRouter Cyber Inference`: cyber models for an authorized red team, gateway controls for a blue team.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This setup implies that login or connect configures trace run --codex, but Trace never reads the stored OrcaRouter credential; Codex still requires ORCAROUTER_API_KEY in its environment and a provider/model selection. Clarify the required environment/configuration here, or inject the stored credential into the wrapped Codex process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/cli/README.md, line 20:

<comment>This setup implies that `login` or `connect` configures `trace run --codex`, but Trace never reads the stored OrcaRouter credential; Codex still requires `ORCAROUTER_API_KEY` in its environment and a provider/model selection. Clarify the required environment/configuration here, or inject the stored credential into the wrapped Codex process.</comment>

<file context>
@@ -15,6 +15,22 @@ b) [Attestation Crafting Process](https://docs.chainloop.dev/getting-started/att
 
+## OrcaRouter Cyber Inference
+
+`chainloop orcarouter` configures [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible inference gateway for security teams, so `chainloop trace run --codex` and the Trace end-to-end suite can route a Codex session through `https://api.orcarouter.ai/v1` with `model_providers.orcarouter`. The provider is `OrcaRouter Cyber Inference`: cyber models for an authorized red team, gateway controls for a blue team.
+
+Two credential paths end at the same durable `sk-orca-…` API key, and either works alone:
</file context>
Suggested change
`chainloop orcarouter` configures [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible inference gateway for security teams, so `chainloop trace run --codex` and the Trace end-to-end suite can route a Codex session through `https://api.orcarouter.ai/v1` with `model_providers.orcarouter`. The provider is `OrcaRouter Cyber Inference`: cyber models for an authorized red team, gateway controls for a blue team.
`chainloop orcarouter` manages [OrcaRouter](https://www.orcarouter.ai) credentials, model discovery, and direct chat. To route Codex through `https://api.orcarouter.ai/v1`, set `ORCAROUTER_API_KEY` and select a model as `orcarouter:<model id>`; `chainloop trace run --codex` wraps the supplied Codex command and does not read the stored key. The provider is `OrcaRouter Cyber Inference`: cyber models for an authorized red team, gateway controls for a blue team.


Two credential paths end at the same durable `sk-orca-…` API key, and either works alone:

```
chainloop orcarouter login # paste an existing key, or read $ORCAROUTER_API_KEY
chainloop orcarouter connect # OAuth 2.0 + PKCE, loopback redirect
chainloop orcarouter status # masked credential and its generation
chainloop orcarouter models # live GET /v1/models, filtered per capability
chainloop orcarouter logout # remove the stored key
```

The key is stored in the CLI configuration file, next to `auth.token`, and is never printed. `connect` opens `https://www.orcarouter.ai/auth` and receives the code on `127.0.0.1`; the exchange posts to `https://www.orcarouter.ai/api/v1/auth/keys`, never to the inference origin. See the [provider page](https://www.orcarouter.ai/cyber) and the [cyber model catalog](https://www.orcarouter.ai/models?tier=cyber).

## Runbook

We use `make` for most development tasks. Run `make -C app/cli` to see a list of the available tasks.
Expand Down
374 changes: 374 additions & 0 deletions app/cli/cmd/orcarouter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/chainloop-dev/chainloop/internal/orcarouter"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// Configuration keys of the OrcaRouter credential. It lives next to the
// existing CLI settings, in the same file the other credentials use, so no
// second secret store is introduced.
const (
// viperKeyOrcaKey is the stored API key.
viperKeyOrcaKey = "orcarouter.api-key"
// viperKeyOrcaSource records which adapter produced the stored key.
viperKeyOrcaSource = "orcarouter.credential-source"
)

// newOrcaRouterCmd creates the `orcarouter` command group. Both credential
// paths are discoverable subcommands: `login` pastes an existing key, `connect`
// runs the OAuth 2.0 + PKCE flow. They end at the same stored credential.
func newOrcaRouterCmd() *cobra.Command {
cmd := &cobra.Command{
Use: orcarouter.ID,
Short: "Configure " + orcarouter.Name,
Long: orcarouter.Name + ` is an OpenAI-compatible inference gateway for security teams.

Cyber models for your red team, gateway controls for your blue team: it admits
authorized, scoped security inference that general gateways often refuse or
silently downgrade. See ` + orcarouter.DocsURL + ` and the cyber model catalog at
` + orcarouter.ModelsURL + `.

Two credential paths end at the same durable API key, and either one works on
its own:

chainloop orcarouter login paste an existing sk-orca-... key
chainloop orcarouter connect authorize in a browser with OAuth 2.0 + PKCE

Authentication uses ` + orcarouter.AuthBaseURL + `; inference and model discovery use
` + orcarouter.BaseURL + `.`,
Annotations: map[string]string{skipActionOptsInit: trueString},
}

cmd.AddCommand(
newOrcaRouterLoginCmd(),
newOrcaRouterConnectCmd(),
newOrcaRouterStatusCmd(),
newOrcaRouterLogoutCmd(),
newOrcaRouterModelsCmd(),
newOrcaRouterChatCmd(),
)

return cmd
}

// orcaRouterManager builds the credential manager over the CLI configuration
// file.
func orcaRouterManager() (*orcarouter.Manager, error) {
return orcarouter.NewManager(viperStore{})
}

// viperStore persists the credential in the CLI configuration file, the store
// the other credentials already use.
type viperStore struct{}

// Load implements orcarouter.Store.
func (viperStore) Load() (string, orcarouter.Source, error) {
key := strings.TrimSpace(viper.GetString(viperKeyOrcaKey))
source := orcarouter.Source(viper.GetString(viperKeyOrcaSource))
return key, source, nil
}

// Save implements orcarouter.Store.
func (viperStore) Save(key string, source orcarouter.Source) error {
viper.Set(viperKeyOrcaKey, key)
viper.Set(viperKeyOrcaSource, string(source))
return viper.WriteConfig()
}

// Clear implements orcarouter.Store. The key is overwritten with an empty
// value and the source is dropped, so no credential remains in the file.
func (viperStore) Clear() error {
viper.Set(viperKeyOrcaKey, "")
viper.Set(viperKeyOrcaSource, "")
return viper.WriteConfig()
}

// newOrcaRouterLoginCmd pastes an existing API key.
func newOrcaRouterLoginCmd() *cobra.Command {
var key string
cmd := &cobra.Command{
Use: "login",
Short: "Store an existing " + orcarouter.Name + " API key",
Long: `Store an existing ` + orcarouter.Name + ` API key.

Create or copy a key at ` + orcarouter.DocsURL + `. The key is checked for its
documented shape only: the first request is what proves it works. It is written
to the CLI configuration file and is never printed.
`,
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, _ []string) error {
secret := key
if secret == "" {
secret = strings.TrimSpace(os.Getenv(orcarouter.EnvKey))
}
if secret == "" {
fmt.Fprintf(cmd.OutOrStdout(), "Paste the %s API key: ", orcarouter.Name)
entered, err := readPasswordFromTerminal()
if err != nil {
return fmt.Errorf("read the API key: %w", err)
}
secret = strings.TrimSpace(string(entered))
}

manager, err := orcaRouterManager()
if err != nil {
return err
}
cred, err := manager.SetAPIKey(secret)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Stored %s credential %s (source: %s)\n", orcarouter.Name, cred.Redacted().Key, cred.Source)
return nil
},
}
cmd.Flags().StringVar(&key, "key", "", "API key to store; reads $"+orcarouter.EnvKey+" or prompts when empty")
return cmd
}

// newOrcaRouterConnectCmd runs the OAuth 2.0 + PKCE login.
func newOrcaRouterConnectCmd() *cobra.Command {
var noBrowser bool
cmd := &cobra.Command{
Use: "connect",
Short: "Authorize with an " + orcarouter.Name + " account (OAuth 2.0 + PKCE)",
Long: `Authorize this machine with an ` + orcarouter.Name + ` account.

The login opens a browser, receives the code on a loopback redirect, and
exchanges it for a normal API key that belongs to your account: it is billed to
you and revocable at ` + orcarouter.ConsoleURL + `. No client secret is involved,
because PKCE binds the code to this process.

The key is durable and is reused until it is revoked. Re-authorizing on every
launch would exhaust the per-user issuance limit.

When no browser can be opened, the authorization URL is printed so it can be
visited elsewhere; the loopback callback still receives the result.
`,
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, _ []string) error {
manager, err := orcaRouterManager()
if err != nil {
return err
}

opts := orcarouter.ConnectOptions{AppName: "Chainloop"}
if noBrowser {
opts.OpenBrowser = func(u string) error {
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to authorize:\n\n %s\n\n", u)
return nil
}
}
cred, err := orcarouter.Connect(cmd.Context(), opts)
if err != nil {
return describeConnectError(err)
}
stored, err := manager.SetPKCE(cred)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Connected. Stored %s credential %s (scope: %s)\n", orcarouter.Name, stored.Redacted().Key, stored.Scope)
return nil
},
}
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "print the authorization URL instead of opening a browser")
return cmd
}

// describeConnectError turns a login failure into an actionable message without
// echoing any response body.
func describeConnectError(err error) error {
switch {
case errors.Is(err, orcarouter.ErrAccessDenied):
return errors.New("authorization was declined; nothing was stored")
case errors.Is(err, orcarouter.ErrStateMismatch):
return errors.New("the authorization response did not belong to this attempt; run the command again")
case errors.Is(err, orcarouter.ErrExchangeRejected):
return errors.New("the authorization code was rejected or already used; run the command again")
case errors.Is(err, orcarouter.ErrScopeDowngrade):
return fmt.Errorf("%w; the account cannot grant the requested scope", err)
case errors.Is(err, context.DeadlineExceeded):
return errors.New("timed out waiting for authorization; run the command again")
default:
return err
}
}

// newOrcaRouterStatusCmd shows the credential without revealing it.
func newOrcaRouterStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show the configured " + orcarouter.Name + " credential",
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, _ []string) error {
manager, err := orcaRouterManager()
if err != nil {
return err
}
cred, ok := manager.Current()
if !ok {
fmt.Fprintf(cmd.OutOrStdout(), "No %s credential. Run `chainloop orcarouter login` or `chainloop orcarouter connect`.\n", orcarouter.Name)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "Provider: %s\nBase URL: %s\nCredential: %s\nSource: %s\nGeneration: %d\nNeeds reauth: %t\n",
orcarouter.Name, orcarouter.APIOrigin(), cred.Redacted().Key, cred.Source, cred.Generation, manager.NeedsReauth())
return nil
},
}
}

// newOrcaRouterLogoutCmd clears the stored credential.
func newOrcaRouterLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove the stored " + orcarouter.Name + " credential",
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, _ []string) error {
manager, err := orcaRouterManager()
if err != nil {
return err
}
if err := manager.Clear(); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Removed the stored credential. Revoke the key itself at "+orcarouter.ConsoleURL+".")
return nil
},
}
}

// newOrcaRouterModelsCmd lists the live catalog for one capability, so the
// filtering rules are visible from the terminal.
func newOrcaRouterModelsCmd() *cobra.Command {
var (
capability string
imageInput bool
)
cmd := &cobra.Command{
Use: "models",
Short: "List the " + orcarouter.Name + " models available to this credential",
Long: `List the models that ` + orcarouter.BaseURL + `/models returns for this credential,
filtered for one entry point.

The live catalog is authoritative. When it cannot be reached, a small verified
fallback list is shown and marked degraded, so a fresh installation is not left
with an empty selector.
`,
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, _ []string) error {
manager, err := orcaRouterManager()
if err != nil {
return err
}
cred, ok := manager.Current()
if !ok {
return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential)
}

cat := orcarouter.DiscoverCatalog(cmd.Context(), nil, orcarouter.APIOrigin(), cred)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: models treats a 401 as degraded discovery and never marks the requesting credential generation for reauthentication, then lists the seed models as usable. Mark the generation and return an actionable reauthentication error for ErrNeedsReauth instead of falling back.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/cli/cmd/orcarouter.go, line 292:

<comment>`models` treats a 401 as degraded discovery and never marks the requesting credential generation for reauthentication, then lists the seed models as usable. Mark the generation and return an actionable reauthentication error for `ErrNeedsReauth` instead of falling back.</comment>

<file context>
@@ -0,0 +1,374 @@
+				return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential)
+			}
+
+			cat := orcarouter.DiscoverCatalog(cmd.Context(), nil, orcarouter.APIOrigin(), cred)
+			if cat.Degraded {
+				fmt.Fprintf(cmd.OutOrStderr(), "Live discovery failed (%v); showing the verified fallback catalog.\n", cat.Err)
</file context>

if cat.Degraded {
fmt.Fprintf(cmd.OutOrStderr(), "Live discovery failed (%v); showing the verified fallback catalog.\n", cat.Err)
}

var required []string
if imageInput {
required = append(required, "image")
}
models := orcarouter.ForCapability(cat.Models, orcarouter.Capability(capability), required)

if len(models) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "No models for capability %q.\n", capability)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "%s (%s, %d models)\n", orcarouter.Name, cat.Source, len(models))
for _, model := range models {
fmt.Fprintln(cmd.OutOrStdout(), model.ID)
}
return nil
},
}
cmd.Flags().StringVar(&capability, "capability", string(orcarouter.CapabilityChat), "capability to list: chat, embedding, image, video, rerank")
cmd.Flags().BoolVar(&imageInput, "image-input", false, "only models that declare image input")
return cmd
}

// newOrcaRouterChatCmd sends one prompt through the provider. It is the
// smallest end-to-end path through the inference client, for verifying a
// credential and the wiring without an agent harness.
func newOrcaRouterChatCmd() *cobra.Command {
var model string
cmd := &cobra.Command{
Use: "chat <prompt>",
Short: "Send one prompt through " + orcarouter.Name,
Long: `Send one prompt to ` + orcarouter.BaseURL + ` with the stored credential.

The model is an ID from ` + orcarouter.ModelsURL + `; it defaults to
` + orcarouter.DefaultModel + `, which routes automatically. Inference and model
discovery use the inference origin, never the authentication origin.
`,
Args: cobra.ExactArgs(1),
Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, args []string) error {
return orcaRouterChat(cmd.Context(), cmd.OutOrStdout(), model, args[0])
},
}
cmd.Flags().StringVar(&model, "model", "", "model ID (default "+orcarouter.DefaultModel+")")
return cmd
}

// orcaRouterChat sends one chat request through the provider. It exists so the
// wiring of the inference path can be exercised without another harness.
func orcaRouterChat(ctx context.Context, out io.Writer, model, prompt string) error {
manager, err := orcaRouterManager()
if err != nil {
return err
}
cred, ok := manager.Current()
if !ok {
return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential)
}
if model == "" {
model = orcarouter.DefaultModel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The chat command sends orcarouter/auto even when the current credential's live catalog does not offer that model, and it accepts arbitrary IDs despite saying models come from the catalog. Select or revalidate the default against live discovery, or require an explicitly listed --model instead of defaulting to an inaccessible recommendation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/cli/cmd/orcarouter.go, line 355:

<comment>The chat command sends `orcarouter/auto` even when the current credential's live catalog does not offer that model, and it accepts arbitrary IDs despite saying models come from the catalog. Select or revalidate the default against live discovery, or require an explicitly listed `--model` instead of defaulting to an inaccessible recommendation.</comment>

<file context>
@@ -0,0 +1,374 @@
+		return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential)
+	}
+	if model == "" {
+		model = orcarouter.DefaultModel
+	}
+
</file context>

}

generation := manager.Generation()
client := &orcarouter.Client{}
answer, err := client.Chat(ctx, cred, orcarouter.ChatRequest{
Model: model,
Messages: []orcarouter.Message{{Role: "user", Content: prompt}},
})
if err != nil {
// A rejected durable key needs a new login; it is not refreshable.
if errors.Is(err, orcarouter.ErrNeedsReauth) {
manager.Rejected(generation)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 401 reauthentication marker is lost when the chat process exits, so a subsequent orcarouter status cannot tell the user that the stored key was rejected. Persist the rejected generation (or another reauthentication marker) alongside the credential and reload it when constructing the manager.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/cli/cmd/orcarouter.go, line 367:

<comment>The 401 reauthentication marker is lost when the `chat` process exits, so a subsequent `orcarouter status` cannot tell the user that the stored key was rejected. Persist the rejected generation (or another reauthentication marker) alongside the credential and reload it when constructing the manager.</comment>

<file context>
@@ -0,0 +1,374 @@
+	if err != nil {
+		// A rejected durable key needs a new login; it is not refreshable.
+		if errors.Is(err, orcarouter.ErrNeedsReauth) {
+			manager.Rejected(generation)
+			return fmt.Errorf("%w: run `chainloop orcarouter connect` or `chainloop orcarouter login`", err)
+		}
</file context>

return fmt.Errorf("%w: run `chainloop orcarouter connect` or `chainloop orcarouter login`", err)
}
return err
}
fmt.Fprintln(out, answer)
return nil
}
Loading