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
45 changes: 26 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@

# Phoenix Secrets

Phoenix Secrets is a self-hosted secrets manager purpose-built for AI agent workflows.
Phoenix Secrets is a self-hosted secrets manager built for AI agent workflows.

When a secret enters an LLM's context — through a prompt, tool response, or log — you are trusting the model provider's entire infrastructure with that value: logging, caching, training pipelines, retention policies. You have no visibility into that chain of custody and no way to revoke it after the fact.
Agents are taking on real work — running tools, installing skills from marketplaces, calling APIs with keys that cost real money. But the security story hasn't caught up. Frameworks like OpenClaw give agents serious capabilities while their credentials sit in plaintext environment variables and config files, readable by every skill and tool in the stack. Making agents smarter does not make them security-conscious. That gap is what Phoenix was built to close.

Phoenix keeps secrets out of model context entirely. That is what **context-safe** means: an agent can use a secret to do its job without the raw value ever appearing in prompts, tool responses, logs, or the model's context window.
Phoenix introduces what we call **context-safe** secrets management: an agent can use a secret to do its job without the raw value ever appearing in prompts, tool responses, logs, or the model's context window. When a secret enters an LLM's context — through any of those paths — you are trusting the model provider's entire infrastructure with that value. You have no visibility into that chain of custody and no way to revoke it after the fact. No other secrets tool treats keeping values out of model context as a design goal.

No other secrets tool treats this as a design goal. Traditional managers like Vault and SOPS assume the consumer is a trusted process. Framework secret-reference features (like OpenClaw's SecretRef) solve config hygiene — they keep values out of config files, but not out of model context. Phoenix provides the actual security layer underneath all of them.
Traditional managers like Vault and SOPS assume the consumer is a trusted process. Framework features like OpenClaw's SecretRef solve config hygiene — keeping values out of config files — but not out of model context. Phoenix is the security layer underneath.

**The OpenClaw problem:** OpenClaw came out of nowhere and changed the way the world thinks about using agents in their daily lives. But it also introduced significant security risks. Skills run with the same privileges as the agent — any skill, including ones downloaded from a marketplace, can read your environment variables and config files. If your API keys are in the environment, every skill you install has access to all of them. OpenClaw's own threat model calls this out as a critical open risk, and there is no sandboxing or credential isolation for skills today. Phoenix was built to close that gap — not just context-safety, but encrypted storage, per-agent access control, audit logging, and credential isolation that OpenClaw doesn't have on its own. See [Integrations](docs/integrations.md) for the full OpenClaw setup.

```bash
# Store a secret
Expand All @@ -38,9 +40,9 @@ phoenix resolve phoenix://myapp/api-key

## Why Phoenix Secrets?

You know your setup is sketchy. Raw API keys in `.env` files, pasted into prompts, passed through scripts, scattered across agent configs. But secret management shouldn't become a full-time job, and enterprise platforms like Vault are overkill for a team of five.
You know your setup is sketchy. Raw API keys in `.env` files, pasted into prompts, passed through scripts, scattered across agent configs. If you're running an agent framework like OpenClaw, every skill you install gets the same access to those credentials that your agent has — and the ecosystem is moving fast enough that nobody has stopped to fix that yet. Secret management shouldn't become a full-time job, but ignoring it isn't an option anymore either. Enterprise platforms like Vault are overkill for a team of five.

Phoenix is built for self-hosted, homelab, small-team, and internal deployments where you want real controls without the overhead. No database, no cloud account, no external service required — and your agent can help you set the whole thing up:
Phoenix is built for the people actually running agents today — self-hosted, homelab, small-team, and internal deployments where you want real controls without the overhead. No database, no cloud account, no external service required — and your agent can help you set the whole thing up:

- **Context-safe delivery** — secrets stay out of agent prompts, tool output, and logs
- **Encrypted storage** — AES-256-GCM envelope encryption with per-namespace keys
Expand All @@ -56,20 +58,20 @@ Phoenix is built for self-hosted, homelab, small-team, and internal deployments

Two binaries (`phoenix` client + `phoenix-server`), single codebase, no external runtime dependencies.

**Sealed responses** use per-agent key pairs (X25519) so the server encrypts each response to a specific agent. Even on a shared host, one agent cannot read another's secrets. In MCP mode, tool output contains opaque tokens instead of plaintext — the raw value never enters the model's context. See [Sealed Responses](docs/sealed-responses.md).
**Sealed responses** mean one agent cannot read another agent's secrets, even on the same machine. The server encrypts each response to a specific agent's key pair, so intercepting the response is useless without the matching private key. In MCP mode, tool output contains opaque tokens instead of plaintext — the raw value never enters the model's context. Under the hood this is X25519 key agreement with per-response encryption. See [Sealed Responses](docs/sealed-responses.md).

Because "just trust the model stack with your secrets" is not a serious security plan.

| | `.env` / framework refs | Phoenix Secrets |
|---|---|---|
| Plaintext out of configs | Yes | Yes |
| Plaintext out of model context | No | Yes — sealed responses + exec isolation |
| Encrypted at rest | No | AES-256-GCM envelope encryption |
| Per-agent access control | No | Glob-pattern ACL per agent |
| Identity attestation | No | mTLS + policy + optional local attestation |
| Runtime audit trail | No | Every access logged with identity |
| Credential stripping | No | `phoenix exec` strips broker creds |
| Key rotation | No | Two-phase commit KEK rotation |
| | `.env` files | OpenClaw SecretRef | Phoenix Secrets |
|---|---|---|---|
| Secrets out of config files | No | Yes | Yes |
| Secrets out of model context | No | No | Yes |
| Encrypted at rest | No | No | Yes (AES-256-GCM) |
| Per-agent access control | No | No | Yes |
| Identity attestation | No | No | Yes (mTLS + policy) |
| Runtime audit trail | No | No | Yes |
| Credential stripping | No | No | Yes |
| Key rotation | No | No | Yes |

---

Expand Down Expand Up @@ -102,14 +104,17 @@ phoenix-server --config /data/phoenix/config.json
### 4. Store and use a secret

```bash
export PHOENIX_SERVER="https://localhost:9090"
export PHOENIX_SERVER="http://127.0.0.1:9090"
export PHOENIX_TOKEN="<your-admin-token>"
export PHOENIX_CA_CERT="/data/phoenix/ca.crt"

phoenix set myapp/api-key -v "sk-live-abc123" -d "API key"
phoenix exec --env API_KEY=phoenix://myapp/api-key -- env | grep API_KEY
```

> The default server config listens on `127.0.0.1:9090` over plain HTTP.
> This is safe for local-only use. For LAN or production deployment,
> enable TLS — see [LAN Deployment](docs/lan-deployment.md).

For the full first-run walkthrough, see [Getting Started](docs/getting-started.md).

---
Expand All @@ -121,6 +126,8 @@ For the full first-run walkthrough, see [Getting Started](docs/getting-started.m
| First install and setup | [Getting Started](docs/getting-started.md) |
| CLI commands and workflows | [CLI Usage](docs/cli-usage.md) |
| Auth, mTLS, and identity | [Authentication](docs/authentication.md) |
| Session tokens and role-based access | [Session Identity](docs/session-identity.md) |
| Operator dashboard (browser UI) | [Dashboard](docs/dashboard.md) |
| Per-path policy and attestation | [Policy and Attestation](docs/policy-and-attestation.md) |
| Sealed responses and multi-agent | [Sealed Responses](docs/sealed-responses.md) / [Multi-Agent Setup](docs/multi-agent-setup.md) |
| MCP, SDKs, OpenClaw, API | [Integrations](docs/integrations.md) |
Expand Down
62 changes: 60 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,64 @@
# Release Notes (Draft)
# Release Notes

## Unreleased
## v0.13.3 (2026-03-20)

### Session Identity

Role-based session tokens replace static bearer tokens for agent access.

- **Named roles** — define namespace scope, allowed actions, bootstrap trust,
and optional step-up approval per role in server config
- **Session tokens** — short-lived (`phxs_` prefix), scoped credentials with
auto-renewal and explicit revocation
- **Bootstrap trust** — roles declare which auth methods (bearer, mTLS, local,
token) can mint sessions
- **Step-up approval** — roles with `step_up: true` require human confirmation
via `phoenix approve` before the session is granted
- **CLI** — `phoenix sessions list|info|revoke` for session management
- **SDK** — `NewWithRole()`, `MintSession()`, `ListSessions()`, `RevokeSession()`,
and error classification helpers (`IsSessionExpired`, `IsSessionRevoked`,
`IsScopeExceeded`, `IsApprovalRequired`, `IsActionDenied`)
- **MCP** — auto-mint via `PHOENIX_ROLE`, background renewal,
`phoenix_session_list` and `phoenix_session_revoke` tools, agent-friendly
denial messages with remediation hints
- **Structured denials** — machine-readable denial codes on all session and
access control failures (SESSION_EXPIRED, SCOPE_EXCEEDED, etc.)
- **Audit** — full session lifecycle audit trail: mint, renew, revoke, auth
failures with session context, step-up approval workflow events
- **Access isolation** — session tokens can only inspect/revoke their own
exact session; no ACL escalation from scoped credentials

### MCP Streamable HTTP Transport

The MCP server now supports Streamable HTTP in addition to stdio:

```bash
phoenix mcp-server --http 127.0.0.1:8080 --mcp-token <token>
```

Endpoint: `/mcp`. Auth via `Authorization: Bearer <mcp-token>`. Tool identity
headers carry through for policy evaluation.

### Operator Dashboard

Lightweight browser-based operator UI at `/dashboard/` — no external
dependencies, all assets embedded via `go:embed`.

- **Overview** — secret/agent/session counts, server uptime, recent audit
- **Approvals** — pending step-up approvals as cards with full context;
approve/deny with shared safety checks (role, bootstrap, attestation, seal key)
- **Sessions** — active sessions table with filters and one-click revoke
- **Audit** — filterable audit log with auto-refresh
- **Roles** — read-only role inspection with namespace/action/trust pills
- **Auth** — cookie-based with HMAC-signed tokens, bcrypt password or PIN,
CSRF protection on all mutations (including logout), `Secure` cookie
flag auto-detected from TLS
- **Rate limiting** — exponential backoff per source IP on login
- **Audit** — full lifecycle: login success/failure, logout, expired session
rejection, CSRF failures, approve/deny/revoke actions; post-login actions
tagged `dashboard@<ip>` for per-operator distinction
- **Mobile** — responsive layout with bottom nav bar on small screens
- **Design** — dark industrial theme, phoenix red-orange accent gradient

### Infrastructure & Publishing
- Added GitHub Actions repository secrets required for Docker publishing:
Expand Down
162 changes: 162 additions & 0 deletions cmd/phoenix-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//
// phoenix-server [--config /data/config.json]
// phoenix-server --init /data (first-time setup)
// phoenix-server --reissue-cert --san <ip-or-host> [--san ...] --config /data/config.json
package main

import (
Expand All @@ -15,30 +16,49 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"

"golang.org/x/crypto/bcrypt"

"github.com/phoenixsec/phoenix/internal/acl"
"github.com/phoenixsec/phoenix/internal/agent"
"github.com/phoenixsec/phoenix/internal/api"
"github.com/phoenixsec/phoenix/internal/approval"
"github.com/phoenixsec/phoenix/internal/audit"
"github.com/phoenixsec/phoenix/internal/ca"
"github.com/phoenixsec/phoenix/internal/config"
"github.com/phoenixsec/phoenix/internal/crypto"
"github.com/phoenixsec/phoenix/internal/dashboard"
"github.com/phoenixsec/phoenix/internal/nonce"
"github.com/phoenixsec/phoenix/internal/op"
"github.com/phoenixsec/phoenix/internal/policy"
"github.com/phoenixsec/phoenix/internal/session"
"github.com/phoenixsec/phoenix/internal/store"
"github.com/phoenixsec/phoenix/internal/token"
"github.com/phoenixsec/phoenix/internal/version"
)

// sanList is a flag.Value that collects multiple --san values.
type sanList []string

func (s *sanList) String() string { return strings.Join(*s, ",") }
func (s *sanList) Set(v string) error {
*s = append(*s, v)
return nil
}

func main() {
configPath := flag.String("config", "", "Path to config file (default: /data/config.json)")
initDir := flag.String("init", "", "Initialize a new Phoenix data directory")
showVersion := flag.Bool("version", false, "Print version and exit")
passphraseStdin := flag.Bool("passphrase-stdin", false, "Read master key passphrase from stdin")
initPassphrase := flag.String("passphrase", "", "Passphrase to protect master key (--init only)")
protectKey := flag.Bool("protect-key", false, "Add, change, or remove passphrase on existing master key")
hashPassword := flag.Bool("hash-password", false, "Generate a bcrypt hash for dashboard password_hash config")
reissueCert := flag.Bool("reissue-cert", false, "Re-issue the server TLS certificate with new SANs")
var sans sanList
flag.Var(&sans, "san", "Subject Alternative Name for --reissue-cert (repeatable: IPs and hostnames)")
flag.Parse()

if *initPassphrase != "" {
Expand All @@ -50,6 +70,34 @@ func main() {
return
}

if *hashPassword {
if err := runHashPassword(); err != nil {
log.Fatalf("hash-password failed: %v", err)
}
return
}

if *reissueCert {
if len(sans) == 0 {
log.Fatal("--reissue-cert requires at least one --san value")
}
cfgPath := *configPath
if cfgPath == "" {
cfgPath = os.Getenv("PHOENIX_CONFIG")
}
if cfgPath == "" {
cfgPath = "/data/config.json"
}
cfg, err := config.Load(cfgPath)
if err != nil {
log.Fatalf("loading config: %v", err)
}
if err := runReissueCert(cfg, sans); err != nil {
log.Fatalf("reissue-cert failed: %v", err)
}
return
}

if *initDir != "" {
if err := runInit(*initDir, *initPassphrase); err != nil {
log.Fatalf("init failed: %v", err)
Expand Down Expand Up @@ -212,6 +260,67 @@ func main() {
log.Printf(" Short-lived tokens: disabled")
}

// Initialize session identity if enabled
var sessionStore *session.Store
var approvalStore *approval.Store
if cfg.Session.Enabled {
sessionTTL := session.DefaultTTL
if cfg.Session.TTL != "" {
sessionTTL, _ = time.ParseDuration(cfg.Session.TTL) // validated above
}
var err error
sessionStore, err = session.NewStore(sessionTTL)
if err != nil {
log.Fatalf("initializing session store: %v", err)
}
srv.SetSessionStore(sessionStore)
srv.SetSessionRoles(cfg.Session.Roles)
defer sessionStore.Stop()
log.Printf(" Sessions: enabled (roles=%d, ttl=%s)", len(cfg.Session.Roles), sessionTTL)

// Check if any role requires step-up approval
hasStepUp := false
var stepUpTimeout time.Duration
for _, role := range cfg.Session.Roles {
if role.StepUp {
hasStepUp = true
if role.StepUpTTL != "" {
if d, err := time.ParseDuration(role.StepUpTTL); err == nil && stepUpTimeout == 0 {
stepUpTimeout = d
}
}
}
}
if hasStepUp {
approvalStore = approval.NewStore(stepUpTimeout)
srv.SetApprovalStore(approvalStore)
defer approvalStore.Stop()
log.Printf(" Step-up approvals: enabled (timeout=%s)", stepUpTimeout)
}
} else {
log.Printf(" Sessions: disabled")
}

// Initialize dashboard if enabled
if cfg.Dashboard.Enabled {
deps := dashboard.Deps{
Sessions: sessionStore,
SessionRoles: cfg.Session.Roles,
Approvals: approvalStore,
AuditPath: cfg.Audit.Path,
Backend: backend,
ACL: a,
StartTime: time.Now(),
Audit: al,
}
dash, err := dashboard.New(cfg.Dashboard, deps)
if err != nil {
log.Fatalf("initializing dashboard: %v", err)
}
srv.SetDashboard(dash)
log.Printf(" Dashboard: enabled at /dashboard/")
}

// Start local attestation agent if enabled
if cfg.Attestation.LocalAgent.Enabled {
localAgent := agent.New(cfg.Attestation.LocalAgent.SocketPath)
Expand Down Expand Up @@ -430,3 +539,56 @@ func runProtectKey(keyPath string) error {

return nil
}

// runHashPassword prompts for a password and prints its bcrypt hash.
// The hash can be pasted into config.json as dashboard.password_hash.
func runHashPassword() error {
pass, err := crypto.PromptPassphrase("Enter dashboard password: ")
if err != nil {
return err
}
confirm, err := crypto.PromptPassphrase("Confirm dashboard password: ")
if err != nil {
return err
}
if pass != confirm {
return fmt.Errorf("passwords do not match")
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("generating hash: %w", err)
}
fmt.Println(string(hash))
return nil
}

func runReissueCert(cfg *config.Config, sans []string) error {
if cfg.Auth.MTLS.CACert == "" || cfg.Auth.MTLS.CAKey == "" {
return fmt.Errorf("config is missing auth.mtls.ca_cert and auth.mtls.ca_key paths — run --init first")
}
if cfg.Auth.MTLS.ServerCert == "" || cfg.Auth.MTLS.ServerKey == "" {
return fmt.Errorf("config is missing auth.mtls.server_cert and auth.mtls.server_key paths")
}

// Always include localhost and loopback
allSANs := append([]string{"localhost", "127.0.0.1"}, sans...)

authority, err := ca.LoadCA(cfg.Auth.MTLS.CACert, cfg.Auth.MTLS.CAKey)
if err != nil {
return fmt.Errorf("loading CA: %w", err)
}

bundle, err := authority.IssueServerCert(allSANs)
if err != nil {
return fmt.Errorf("issuing server cert: %w", err)
}

if err := bundle.Save(cfg.Auth.MTLS.ServerCert, cfg.Auth.MTLS.ServerKey, cfg.Auth.MTLS.CACert); err != nil {
return fmt.Errorf("saving server cert: %w", err)
}

fmt.Printf("Server certificate reissued: %s\n", cfg.Auth.MTLS.ServerCert)
fmt.Printf("SANs: %s\n", strings.Join(allSANs, ", "))
fmt.Println("Restart the server for the new certificate to take effect.")
return nil
}
Loading
Loading