From c8da64b93b8e4d90adce06a80443229f5e5b61e7 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Mon, 27 Apr 2026 00:44:48 +0000 Subject: [PATCH 01/11] feat(openclaw): add exec provider adapter --- README.md | 2 +- RELEASE_NOTES.md | 10 ++ cmd/phoenix/main.go | 176 +++++++++++++++++++++ cmd/phoenix/openclaw_exec_provider_test.go | 152 ++++++++++++++++++ docs/cli-usage.md | 12 ++ docs/integrations.md | 152 +++++++++++++----- internal/version/version.go | 2 +- 7 files changed, 465 insertions(+), 41 deletions(-) create mode 100644 cmd/phoenix/openclaw_exec_provider_test.go diff --git a/README.md b/README.md index 37676dd..139b9f1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Phoenix is built for the people actually running agents today — self-hosted, h - **Reference-first workflows** — `phoenix://` URIs replace raw values in configs and scripts - **Exec credential stripping** — `phoenix exec` injects secrets and strips broker credentials from the child process - **Works with existing setups** — `phoenix import` pulls from `.env` files in one command; 1Password users can migrate secrets or broker reads at runtime without moving anything -- **Agent-native integrations** — built-in MCP server for Claude Code / Claude Desktop, Python/Go/TypeScript SDKs, OpenClaw exec backend, direct HTTP API +- **Agent-native integrations** — built-in MCP server for Claude Code / Claude Desktop, Python/Go/TypeScript SDKs, OpenClaw SecretRef exec provider and plugin-tool path, direct HTTP API - **Emergency offline access** — break-glass secret retrieval directly from disk when the server is down Two binaries (`phoenix` client + `phoenix-server`), single codebase, no external runtime dependencies. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 481c1f1..5a724f0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,15 @@ # Release Notes +## v0.14.0 (2026-04-27) + +### OpenClaw SecretRef Exec Provider + +- Added `phoenix openclaw-exec-provider` for OpenClaw's built-in exec SecretRef provider protocol. +- Added `phoenix secret-provider openclaw` and `phoenix resolve --stdin-json` aliases for compatibility with OpenClaw exec-provider configuration. +- The provider reads OpenClaw's stdin JSON request, resolves requested ids through Phoenix using the existing auth/session/mTLS/sealed-response paths, and writes OpenClaw-compatible `values`/`errors` JSON to stdout. +- Added tests for request parsing, stdout response shape, id normalization, and partial per-id failures. +- Updated OpenClaw integration docs to distinguish bootstrap/config SecretRefs from runtime `openclaw-phoenix` plugin tools, and to stop claiming plain `phoenix resolve ` is the exec-provider protocol. + ## v0.13.5 (2026-04-07) ### Step-Up Authorization Hygiene diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index 0343903..be20d3a 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -13,6 +13,8 @@ // phoenix agent create --token --acl [--force] // phoenix agent list // phoenix resolve [ref...] +// phoenix resolve --stdin-json +// phoenix openclaw-exec-provider // phoenix exec --env KEY=phoenix://ns/secret [--output-env ] [--timeout ] [--mask-env] -- [args...] // phoenix verify [--dry-run] // phoenix status @@ -162,6 +164,20 @@ func main() { } case "resolve": err = cmdResolve(args) + case "openclaw-exec-provider": + err = cmdOpenClawExecProvider(args) + case "secret-provider": + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: phoenix secret-provider ") + os.Exit(1) + } + switch args[0] { + case "openclaw": + err = cmdOpenClawExecProvider(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown secret-provider subcommand: %s\n", args[0]) + os.Exit(1) + } case "exec": err = cmdExec(args) case "verify": @@ -325,6 +341,9 @@ Usage: phoenix agent list List agents phoenix agent delete Delete an agent phoenix resolve [--signed] [ref...] Resolve phoenix:// references to values + phoenix resolve --stdin-json OpenClaw SecretRef exec provider (stdin/stdout JSON) + phoenix openclaw-exec-provider OpenClaw SecretRef exec provider (stdin/stdout JSON) + phoenix secret-provider openclaw Alias for openclaw-exec-provider phoenix exec --env K=phoenix://n/s -- cmd Run command with resolved secrets as env phoenix exec --output-env --env ... Write resolved env to file (no exec) phoenix exec --timeout 5s --env ... Fail if resolution exceeds duration @@ -1018,7 +1037,164 @@ func cmdAgentDelete(args []string) error { return nil } +type openClawExecProviderRequest struct { + ProtocolVersion int `json:"protocolVersion"` + Provider string `json:"provider"` + IDs []string `json:"ids"` +} + +type openClawExecProviderError struct { + Message string `json:"message"` +} + +type openClawExecProviderResponse struct { + ProtocolVersion int `json:"protocolVersion"` + Values map[string]string `json:"values"` + Errors map[string]openClawExecProviderError `json:"errors,omitempty"` +} + +func parseOpenClawExecProviderRequest(r io.Reader) (*openClawExecProviderRequest, error) { + var req openClawExecProviderRequest + dec := json.NewDecoder(r) + if err := dec.Decode(&req); err != nil { + return nil, fmt.Errorf("decoding OpenClaw exec provider request: %w", err) + } + if req.ProtocolVersion != 1 { + return nil, fmt.Errorf("OpenClaw exec provider protocolVersion must be 1") + } + if req.Provider == "" { + return nil, fmt.Errorf("OpenClaw exec provider request missing provider") + } + if req.IDs == nil { + return nil, fmt.Errorf("OpenClaw exec provider request missing ids") + } + for _, id := range req.IDs { + if strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("OpenClaw exec provider request contains empty id") + } + } + return &req, nil +} + +func openClawIDToPhoenixRef(id string) string { + trimmed := strings.TrimSpace(id) + if strings.HasPrefix(trimmed, "phoenix://") { + return trimmed + } + return "phoenix://" + strings.TrimPrefix(trimmed, "/") +} + +func cmdOpenClawExecProvider(args []string) error { + if len(args) != 0 { + return fmt.Errorf("usage: phoenix openclaw-exec-provider") + } + if err := requireAuth(); err != nil { + return err + } + + req, err := parseOpenClawExecProviderRequest(os.Stdin) + if err != nil { + return err + } + + refs := make([]string, 0, len(req.IDs)) + seenRefs := make(map[string]bool, len(req.IDs)) + for _, id := range req.IDs { + ref := openClawIDToPhoenixRef(id) + if !seenRefs[ref] { + refs = append(refs, ref) + seenRefs[ref] = true + } + } + + sealPrivKey, err := loadSealKeyForRequest() + if err != nil { + return fmt.Errorf("loading seal key: %w", err) + } + + body, _ := json.Marshal(map[string]interface{}{"refs": refs}) + resp, err := apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)), sealHeaders(sealPrivKey)) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return handleError(resp) + } + + values := make(map[string]string) + errs := make(map[string]string) + if sealPrivKey != nil { + var result struct { + SealedValues map[string]interface{} `json:"sealed_values"` + Errors map[string]string `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + errs = result.Errors + for ref, raw := range result.SealedValues { + if envMap, ok := raw.(map[string]interface{}); ok { + if envRef, _ := envMap["ref"].(string); envRef != ref { + return fmt.Errorf("sealed envelope ref mismatch: map key %q, envelope %q", ref, envRef) + } + } + val, err := decryptSealedValue(raw, sealPrivKey) + if err != nil { + return fmt.Errorf("decrypting %s: %w", ref, err) + } + values[ref] = val + } + } else { + var result struct { + Values map[string]string `json:"values"` + Errors map[string]string `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + values = result.Values + errs = result.Errors + } + + out := openClawExecProviderResponse{ + ProtocolVersion: 1, + Values: make(map[string]string), + } + for _, id := range req.IDs { + ref := openClawIDToPhoenixRef(id) + if errMsg, ok := errs[ref]; ok { + if out.Errors == nil { + out.Errors = make(map[string]openClawExecProviderError) + } + out.Errors[id] = openClawExecProviderError{Message: errMsg} + continue + } + val, ok := values[ref] + if !ok { + if out.Errors == nil { + out.Errors = make(map[string]openClawExecProviderError) + } + out.Errors[id] = openClawExecProviderError{Message: "no value returned by Phoenix"} + continue + } + out.Values[id] = val + } + + enc := json.NewEncoder(os.Stdout) + return enc.Encode(out) +} + func cmdResolve(args []string) error { + if len(args) == 1 && args[0] == "--stdin-json" { + return cmdOpenClawExecProvider(nil) + } + for _, arg := range args { + if arg == "--stdin-json" { + return fmt.Errorf("usage: phoenix resolve --stdin-json") + } + } + if err := requireAuth(); err != nil { return err } diff --git a/cmd/phoenix/openclaw_exec_provider_test.go b/cmd/phoenix/openclaw_exec_provider_test.go new file mode 100644 index 0000000..f99c67c --- /dev/null +++ b/cmd/phoenix/openclaw_exec_provider_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" +) + +func TestParseOpenClawExecProviderRequest(t *testing.T) { + req, err := parseOpenClawExecProviderRequest(strings.NewReader(`{"protocolVersion":1,"provider":"phoenix","ids":["api/openrouter","phoenix://bot/token"]}`)) + if err != nil { + t.Fatalf("parseOpenClawExecProviderRequest error: %v", err) + } + if req.ProtocolVersion != 1 || req.Provider != "phoenix" { + t.Fatalf("unexpected request header: %#v", req) + } + if got, want := strings.Join(req.IDs, ","), "api/openrouter,phoenix://bot/token"; got != want { + t.Fatalf("ids = %q, want %q", got, want) + } +} + +func TestParseOpenClawExecProviderRequestRejectsBadProtocol(t *testing.T) { + _, err := parseOpenClawExecProviderRequest(strings.NewReader(`{"protocolVersion":2,"provider":"phoenix","ids":["api/key"]}`)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "protocolVersion must be 1") { + t.Fatalf("error = %v, want protocolVersion message", err) + } +} + +func TestCmdOpenClawExecProviderStdoutShape(t *testing.T) { + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/resolve" { + t.Fatalf("path = %q, want /v1/resolve", r.URL.Path) + } + var body struct { + Refs []string `json:"refs"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if got, want := strings.Join(body.Refs, ","), "phoenix://api/openrouter,phoenix://bot/token"; got != want { + t.Fatalf("refs = %q, want %q", got, want) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"values":{"phoenix://api/openrouter":"openrouter-value","phoenix://bot/token":"bot-token"}}`)) + }, func() { + out := runOpenClawExecProviderWithStdin(t, `{"protocolVersion":1,"provider":"phoenix","ids":["api/openrouter","phoenix://bot/token"]}`) + var resp openClawExecProviderResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode stdout: %v; stdout=%q", err, out) + } + if resp.ProtocolVersion != 1 { + t.Fatalf("protocolVersion = %d, want 1", resp.ProtocolVersion) + } + if got := resp.Values["api/openrouter"]; got != "openrouter-value" { + t.Fatalf("api/openrouter value = %q", got) + } + if got := resp.Values["phoenix://bot/token"]; got != "bot-token" { + t.Fatalf("phoenix://bot/token value = %q", got) + } + if len(resp.Errors) != 0 { + t.Fatalf("errors = %#v, want none", resp.Errors) + } + }) +} + +func TestCmdResolveStdinJSONAlias(t *testing.T) { + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"values":{"phoenix://api/key":"key-value"}}`)) + }, func() { + out := runOpenClawExecProviderCommandWithStdin(t, `{"protocolVersion":1,"provider":"phoenix","ids":["api/key"]}`, func() error { + return cmdResolve([]string{"--stdin-json"}) + }) + var resp openClawExecProviderResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode stdout: %v; stdout=%q", err, out) + } + if got := resp.Values["api/key"]; got != "key-value" { + t.Fatalf("api/key value = %q", got) + } + }) +} + +func TestCmdOpenClawExecProviderPartialFailure(t *testing.T) { + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"values":{"phoenix://ok":"ok-value"},"errors":{"phoenix://missing":"access denied"}}`)) + }, func() { + out := runOpenClawExecProviderWithStdin(t, `{"protocolVersion":1,"provider":"phoenix","ids":["ok","missing"]}`) + var resp openClawExecProviderResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode stdout: %v; stdout=%q", err, out) + } + if got := resp.Values["ok"]; got != "ok-value" { + t.Fatalf("ok value = %q", got) + } + errEntry, ok := resp.Errors["missing"] + if !ok { + t.Fatalf("missing error absent: %#v", resp.Errors) + } + if errEntry.Message != "access denied" { + t.Fatalf("missing error = %q", errEntry.Message) + } + if _, leaked := resp.Values["missing"]; leaked { + t.Fatal("missing id unexpectedly has a value") + } + }) +} + +func runOpenClawExecProviderWithStdin(t *testing.T, stdin string) string { + t.Helper() + return runOpenClawExecProviderCommandWithStdin(t, stdin, func() error { + return cmdOpenClawExecProvider(nil) + }) +} + +func runOpenClawExecProviderCommandWithStdin(t *testing.T, stdin string, run func() error) string { + t.Helper() + + origStdin := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + if _, err := io.Copy(w, bytes.NewBufferString(stdin)); err != nil { + t.Fatalf("write stdin pipe: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close stdin writer: %v", err) + } + os.Stdin = r + defer func() { + os.Stdin = origStdin + _ = r.Close() + }() + + var cmdErr error + out := captureStdout(t, func() { + cmdErr = run() + }) + if cmdErr != nil { + t.Fatalf("cmdOpenClawExecProvider error: %v", cmdErr) + } + return out +} diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 82c6e28..7654abf 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -29,6 +29,18 @@ phoenix resolve phoenix://myapp/db-password phoenix resolve phoenix://myapp/db-password phoenix://myapp/api-key ``` +`phoenix resolve` is the general CLI/script command. For OpenClaw built-in +SecretRefs, use the stdin/stdout provider command instead: + +```bash +phoenix resolve --stdin-json +# aliases: +phoenix openclaw-exec-provider +phoenix secret-provider openclaw +``` + +See [Integrations](integrations.md#openclaw) for the OpenClaw config shape. + ## Exec wrapper ```bash diff --git a/docs/integrations.md b/docs/integrations.md index 0cd7eeb..4b7e506 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -47,57 +47,141 @@ integration without running MCP mode. ## OpenClaw -OpenClaw's own threat model flags plaintext credential storage and credential -harvesting by skills as open, high-severity risks. Phoenix closes that gap -through the exec backend: OpenClaw resolves secrets at runtime via `phoenix resolve`, -so raw values never appear in OpenClaw's environment, config files, or skill -execution context. +Phoenix supports two complementary OpenClaw integration paths. They solve different +problems and should be used together for a production deployment. -### Exec backend config +### 1. Built-in SecretRefs for bootstrap/config secrets + +Use OpenClaw's built-in `{ source: "exec", provider, id }` SecretRefs for secrets +that OpenClaw must resolve while loading gateway/runtime configuration: gateway auth +tokens, model provider API keys, channel bot tokens, webhook secrets, and similar +startup configuration. + +Phoenix provides an OpenClaw-compatible exec provider command: + +```bash +phoenix resolve --stdin-json +# equivalent aliases: +phoenix openclaw-exec-provider +phoenix secret-provider openclaw +``` + +This command reads OpenClaw's exec-provider JSON request from stdin: ```json +{"protocolVersion":1,"provider":"phoenix","ids":["openclaw/shared/openai-api-key"]} +``` + +and writes OpenClaw's expected JSON response to stdout: + +```json +{"protocolVersion":1,"values":{"openclaw/shared/openai-api-key":"..."}} +``` + +Per-id failures are returned under `errors` without logging or printing secret +values. + +Configure the provider in OpenClaw with the Phoenix binary path available to the +OpenClaw process: + +```json5 { - "secrets": { - "providers": { - "phoenix": { - "type": "exec", - "command": "phoenix", - "args": ["resolve"] + secrets: { + providers: { + phoenix: { + source: "exec", + command: "/usr/local/bin/phoenix", + args: ["resolve", "--stdin-json"], + passEnv: [ + "PHOENIX_SERVER", + "PHOENIX_TOKEN", + "PHOENIX_ROLE", + "PHOENIX_CA_CERT", + "PHOENIX_CLIENT_CERT", + "PHOENIX_CLIENT_KEY", + "PHOENIX_SEAL_KEY", + "PHOENIX_TOOL" + ], + timeoutMs: 10000 } + }, + defaults: { + exec: "phoenix" } } } ``` -Use SecretRefs backed by Phoenix in your agent config: +Then use OpenClaw SecretRef objects in fields that support secrets: -```yaml -api_keys: - openai: ${{ secrets.phoenix.phoenix://myapp/openai-key }} - anthropic: ${{ secrets.phoenix.phoenix://myapp/anthropic-key }} +```json5 +{ + providers: { + openai: { + apiKey: { source: "exec", provider: "phoenix", id: "openclaw/shared/openai-api-key" } + } + }, + gateway: { + auth: { + token: { source: "exec", provider: "phoenix", id: "openclaw/gateway/auth-token" } + } + } +} ``` -Set Phoenix credentials for the OpenClaw process: +The exec provider accepts ids either as Phoenix paths (`openclaw/shared/key`) or as +full refs (`phoenix://openclaw/shared/key`). It normalizes paths to Phoenix refs for +resolution, but the stdout `values`/`errors` keys match OpenClaw's input ids. + +Set Phoenix credentials for the OpenClaw process. Prefer scoped role/session or +mTLS credentials over a broad admin token: ```bash export PHOENIX_SERVER=https://phoenix:9090 -export PHOENIX_TOKEN=openclaw-agent-token +export PHOENIX_TOKEN= +export PHOENIX_ROLE=openclaw-gateway # Or use mTLS: export PHOENIX_CA_CERT=/etc/phoenix/ca.crt export PHOENIX_CLIENT_CERT=/etc/phoenix/openclaw.crt export PHOENIX_CLIENT_KEY=/etc/phoenix/openclaw.key ``` -Validate before deploying: +`PHOENIX_ROLE`, mTLS, sealed responses, session auto-mint/renewal, and Phoenix +attestation headers are handled by the same CLI auth paths as `phoenix resolve`. +Plain `phoenix resolve ` remains the general human/script command; use +`phoenix resolve --stdin-json` for OpenClaw's stdin/stdout provider protocol. -```bash -phoenix verify --dry-run gateway-config.yaml -phoenix policy test --agent openclaw --ip 10.0.0.5 myapp/openai-key -``` +### 2. Plugin tools for agent/tool runtime access + +Use the separate `openclaw-phoenix` plugin for agent/tool-time workflows: + +- `phoenix_resolve` for Phoenix-aware runtime resolution +- `phoenix_list` for listing visible paths +- `phoenix_status` for health/connectivity checks +- sealed-response and approval-aware UX as the plugin evolves + +The plugin enhances OpenClaw runtime capabilities, but it does not replace built-in +SecretRef resolution for bootstrap/core config. Installing the plugin alone does not +make startup fields resolve through Phoenix; configure the exec provider above for +that. + +### Migration from `.env` or plaintext OpenClaw config + +1. Import or set secrets in Phoenix under a stable namespace, for example + `openclaw/shared/openai-api-key` and `openclaw/gateway/auth-token`. +2. Create a scoped Phoenix identity for OpenClaw with read access only to the paths + it needs. +3. Add the OpenClaw `secrets.providers.phoenix` exec provider config shown above. +4. Replace plaintext strings in OpenClaw config with `{ source: "exec", provider: + "phoenix", id: "..." }` SecretRefs where OpenClaw supports secret inputs. +5. Restart or reload OpenClaw and verify resolution. Keep plaintext fallback values + out of committed config. ### Containerized deployment (Docker Compose) -In a Docker or Compose setup, Phoenix runs as a sidecar or network-adjacent service. +In Docker or Compose, Phoenix can run as a sidecar or network-adjacent service. +Mount or install the `phoenix` binary into the OpenClaw container and pass only the +Phoenix environment variables needed by the exec provider. ```yaml services: @@ -105,16 +189,16 @@ services: image: phoenixsecdev/phoenix:latest volumes: - phoenix-data:/data/phoenix - # No host port needed — OpenClaw reaches Phoenix via - # the Compose network using the service name "phoenix" openclaw: image: ghcr.io/openclaw/openclaw:latest environment: PHOENIX_SERVER: "http://phoenix:9090" PHOENIX_TOKEN: "${OPENCLAW_PHOENIX_TOKEN}" + PHOENIX_ROLE: "openclaw-gateway" volumes: - ./openclaw-config:/config + - /usr/local/bin/phoenix:/usr/local/bin/phoenix:ro depends_on: - phoenix @@ -122,18 +206,8 @@ volumes: phoenix-data: ``` -For mTLS instead of bearer tokens, mount certs into the OpenClaw container -and set `PHOENIX_CA_CERT`, `PHOENIX_CLIENT_CERT`, `PHOENIX_CLIENT_KEY`. - -### Current limitations - -The exec backend integration works today but requires operator setup: -the Phoenix CLI binary must be available in the OpenClaw container's PATH, and -credentials (token or mTLS certs) must be configured for the OpenClaw process. -A dedicated OpenClaw plugin that handles this automatically is in active -development. Until then, the exec backend is the supported path — fully -functional, but requires explicit wiring in your Compose or deployment -config rather than a one-line plugin install. +For mTLS instead of bearer tokens, mount certs into the OpenClaw container and set +`PHOENIX_CA_CERT`, `PHOENIX_CLIENT_CERT`, and `PHOENIX_CLIENT_KEY`. ## Go SDK diff --git a/internal/version/version.go b/internal/version/version.go index 289002e..61a645d 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.13.5" +var Version = "0.14.0" From 3cf6e90fc3f1ad921abf8a5e74bf10eb9ff8eb8f Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Wed, 17 Jun 2026 04:18:15 -0400 Subject: [PATCH 02/11] fix(openclaw): validate exec provider identity --- RELEASE_NOTES.md | 2 +- cmd/phoenix/main.go | 3 +++ cmd/phoenix/openclaw_exec_provider_test.go | 10 ++++++++++ docs/roadmap.md | 3 ++- internal/version/version.go | 2 +- 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5a724f0..bb7268e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ # Release Notes -## v0.14.0 (2026-04-27) +## v0.15.0 (2026-04-27) ### OpenClaw SecretRef Exec Provider diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index be20d3a..9484ead 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -1065,6 +1065,9 @@ func parseOpenClawExecProviderRequest(r io.Reader) (*openClawExecProviderRequest if req.Provider == "" { return nil, fmt.Errorf("OpenClaw exec provider request missing provider") } + if req.Provider != "phoenix" { + return nil, fmt.Errorf("OpenClaw exec provider request provider must be phoenix") + } if req.IDs == nil { return nil, fmt.Errorf("OpenClaw exec provider request missing ids") } diff --git a/cmd/phoenix/openclaw_exec_provider_test.go b/cmd/phoenix/openclaw_exec_provider_test.go index f99c67c..f071b8b 100644 --- a/cmd/phoenix/openclaw_exec_provider_test.go +++ b/cmd/phoenix/openclaw_exec_provider_test.go @@ -33,6 +33,16 @@ func TestParseOpenClawExecProviderRequestRejectsBadProtocol(t *testing.T) { } } +func TestParseOpenClawExecProviderRequestRejectsNonPhoenixProvider(t *testing.T) { + _, err := parseOpenClawExecProviderRequest(strings.NewReader(`{"protocolVersion":1,"provider":"other","ids":["api/key"]}`)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "provider must be phoenix") { + t.Fatalf("error = %v, want provider message", err) + } +} + func TestCmdOpenClawExecProviderStdoutShape(t *testing.T) { withMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/resolve" { diff --git a/docs/roadmap.md b/docs/roadmap.md index 785d9e8..5ff9356 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,7 @@ - Emergency offline access (break-glass) - Master key passphrase protection and rotation - Internal CA with agent certificate lifecycle and CRL +- OpenClaw SecretRef exec provider (`phoenix openclaw-exec-provider`) ## Planned (Near-Term) @@ -55,4 +56,4 @@ These are under consideration and will be prioritized by real-world usage: --- -Last updated: 2026-03-19 +Last updated: 2026-06-14 diff --git a/internal/version/version.go b/internal/version/version.go index 61a645d..86e152e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.14.0" +var Version = "0.15.0" From 87fbc12f12f342a523f06741cb84e025a3a6425e Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Wed, 17 Jun 2026 04:42:28 -0400 Subject: [PATCH 03/11] feat(audit): capture OpenClaw metadata hints --- RELEASE_NOTES.md | 8 ++ internal/api/api.go | 227 +++++++++++++++++++++++----------- internal/api/api_test.go | 229 +++++++++++++++++++++++++++++++++++ internal/audit/audit.go | 88 +++++++++++--- internal/audit/audit_test.go | 21 ++++ internal/version/version.go | 2 +- 6 files changed, 488 insertions(+), 87 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bb7268e..e0cbc9f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,13 @@ # Release Notes +## v0.15.1 (2026-06-17) + +### OpenClaw Audit Metadata + +- Captures sanitized allowlisted `X-OpenClaw-*` metadata headers on server audit entries as audit hints only. +- Omits raw `X-OpenClaw-Session-Key` from audit records. +- Added tests proving spoofed OpenClaw headers do not affect Phoenix authorization, attestation policy identity, or sealed-response policy decisions. + ## v0.15.0 (2026-04-27) ### OpenClaw SecretRef Exec Provider diff --git a/internal/api/api.go b/internal/api/api.go index 633ec98..805f9ce 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,6 +35,19 @@ import ( // MaxRequestBodyBytes limits the size of request bodies to prevent DoS. const MaxRequestBodyBytes = 1 << 20 // 1 MB +const openClawAuditMetadataMaxValueLen = 256 + +var openClawAuditHeaders = []struct { + header string + key string +}{ + {header: "X-OpenClaw-Agent", key: "openclaw.agent"}, + {header: "X-OpenClaw-Session-Id", key: "openclaw.session_id"}, + {header: "X-OpenClaw-Channel", key: "openclaw.channel"}, + {header: "X-OpenClaw-Requester-Sender", key: "openclaw.requester_sender"}, + {header: "X-OpenClaw-Sender-Is-Owner", key: "openclaw.sender_is_owner"}, +} + // Rate limiting constants for authentication attempts. const ( rateLimitMaxFailures = 5 @@ -316,7 +329,7 @@ func (s *Server) authenticateInfo(r *http.Request) (*authInfo, error) { } // Extract identity for audit even from expired/revoked tokens if agent, sessID, known := s.sessions.ParseClaimsInsecure(tok); known { - s.logAudit(s.audit.LogSessionDenied(agent, "session.auth", "", ip, code, sessID)) + s.auditLogSessionDenied(r, agent, "session.auth", "", ip, code, sessID) } return nil, &sessionAuthError{code: code, err: err} } @@ -466,6 +479,54 @@ func extractToken(r *http.Request) string { return "" } +// openClawAuditMetadata returns sanitized audit-only OpenClaw metadata hints. +// These headers must never participate in authentication, ACL, policy, +// session identity, role mapping, or sealed-response decisions. +func openClawAuditMetadata(r *http.Request) map[string]string { + if r == nil { + return nil + } + metadata := make(map[string]string, len(openClawAuditHeaders)) + for _, h := range openClawAuditHeaders { + value := sanitizeOpenClawAuditMetadataValue(r.Header.Get(h.header)) + if value != "" { + metadata[h.key] = value + } + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +func sanitizeOpenClawAuditMetadataValue(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, value) + value = strings.Join(strings.Fields(value), " ") + if len(value) <= openClawAuditMetadataMaxValueLen { + return value + } + last := 0 + for i := range value { + if i > openClawAuditMetadataMaxValueLen { + break + } + last = i + } + if last == 0 { + return "" + } + return strings.TrimSpace(value[:last]) +} + // clientIP extracts the client IP from the request. // X-Forwarded-For is intentionally ignored — Phoenix is not behind a // reverse proxy, and trusting XFF allows audit log IP spoofing. @@ -529,23 +590,47 @@ func handleAuthError(w http.ResponseWriter, err error) { jsonError(w, "unauthorized", http.StatusUnauthorized) } +func (s *Server) auditLogAllowed(r *http.Request, agent, action, path, ip string) { + s.logAudit(s.audit.LogAllowedWithMetadata(agent, action, path, ip, openClawAuditMetadata(r))) +} + +func (s *Server) auditLogAllowedSealed(r *http.Request, agent, action, path, ip string, sealed bool) { + s.logAudit(s.audit.LogAllowedSealedWithMetadata(agent, action, path, ip, sealed, openClawAuditMetadata(r))) +} + +func (s *Server) auditLogDenied(r *http.Request, agent, action, path, ip, reason string) { + s.logAudit(s.audit.LogDeniedWithMetadata(agent, action, path, ip, reason, openClawAuditMetadata(r))) +} + +func (s *Server) auditLogSessionAllowed(r *http.Request, agent, action, path, ip, sessionID string) { + s.logAudit(s.audit.LogSessionAllowedWithMetadata(agent, action, path, ip, sessionID, openClawAuditMetadata(r))) +} + +func (s *Server) auditLogSessionAllowedSealed(r *http.Request, agent, action, path, ip, sessionID string, sealed bool) { + s.logAudit(s.audit.LogSessionAllowedSealedWithMetadata(agent, action, path, ip, sessionID, sealed, openClawAuditMetadata(r))) +} + +func (s *Server) auditLogSessionDenied(r *http.Request, agent, action, path, ip, reason, sessionID string) { + s.logAudit(s.audit.LogSessionDeniedWithMetadata(agent, action, path, ip, reason, sessionID, openClawAuditMetadata(r))) +} + // auditAllowed logs an allowed action, including session ID when a session was used. -func (s *Server) auditAllowed(info *authInfo, action, path, ip string) { +func (s *Server) auditAllowed(r *http.Request, info *authInfo, action, path, ip string) { if info != nil && info.UsedSession { - s.logAudit(s.audit.LogSessionAllowed(info.Agent, action, path, ip, info.SessionID)) + s.auditLogSessionAllowed(r, info.Agent, action, path, ip, info.SessionID) } else { agent := "" if info != nil { agent = info.Agent } - s.logAudit(s.audit.LogAllowed(agent, action, path, ip)) + s.auditLogAllowed(r, agent, action, path, ip) } } // auditAllowedWithSeal logs an allowed action and includes sealed-response state. -func (s *Server) auditAllowedWithSeal(info *authInfo, action, path, ip string, sealed bool) { +func (s *Server) auditAllowedWithSeal(r *http.Request, info *authInfo, action, path, ip string, sealed bool) { if info != nil && info.UsedSession { - s.logAudit(s.audit.LogSessionAllowedSealed(info.Agent, action, path, ip, info.SessionID, sealed)) + s.auditLogSessionAllowedSealed(r, info.Agent, action, path, ip, info.SessionID, sealed) return } @@ -553,19 +638,19 @@ func (s *Server) auditAllowedWithSeal(info *authInfo, action, path, ip string, s if info != nil { agent = info.Agent } - s.logAudit(s.audit.LogAllowedSealed(agent, action, path, ip, sealed)) + s.auditLogAllowedSealed(r, agent, action, path, ip, sealed) } // auditDenied logs a denied action, including session ID when a session was used. -func (s *Server) auditDenied(info *authInfo, action, path, ip, reason string) { +func (s *Server) auditDenied(r *http.Request, info *authInfo, action, path, ip, reason string) { if info != nil && info.UsedSession { - s.logAudit(s.audit.LogSessionDenied(info.Agent, action, path, ip, reason, info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, action, path, ip, reason, info.SessionID) } else { agent := "" if info != nil { agent = info.Agent } - s.logAudit(s.audit.LogDenied(agent, action, path, ip, reason)) + s.auditLogDenied(r, agent, action, path, ip, reason) } } @@ -580,12 +665,12 @@ func sessionActionAllowed(actions []string, action string) bool { // checkSessionScope verifies the request path is within session namespace scope. // Returns true if access is allowed, false if denied (and writes the error response). -func (s *Server) checkSessionScope(w http.ResponseWriter, info *authInfo, path, ip string) bool { +func (s *Server) checkSessionScope(w http.ResponseWriter, r *http.Request, info *authInfo, path, ip string) bool { if !info.UsedSession { return true } if !session.PathInScope(path, info.SessionNamespaces) { - s.logAudit(s.audit.LogSessionDenied(info.Agent, "scope_check", path, ip, "session_scope", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "scope_check", path, ip, "session_scope", info.SessionID) jsonDenied(w, "access_denied", "SCOPE_EXCEEDED", fmt.Sprintf("path %q is outside session scope for role %q", path, info.SessionRole), "request a session with a role that includes this namespace", @@ -597,14 +682,14 @@ func (s *Server) checkSessionScope(w http.ResponseWriter, info *authInfo, path, // checkSessionAction verifies the requested action is allowed by the session role. // Returns true if access is allowed, false if denied (and writes the error response). -func (s *Server) checkSessionAction(w http.ResponseWriter, info *authInfo, action, path, ip string) bool { +func (s *Server) checkSessionAction(w http.ResponseWriter, r *http.Request, info *authInfo, action, path, ip string) bool { if !info.UsedSession { return true } if sessionActionAllowed(info.SessionActions, action) { return true } - s.logAudit(s.audit.LogSessionDenied(info.Agent, action, path, ip, "session_action", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, action, path, ip, "session_action", info.SessionID) jsonDenied(w, "access_denied", "ACTION_DENIED", fmt.Sprintf("action %q is not permitted by session role %q", action, info.SessionRole), "request a session with a role that includes this action", @@ -698,7 +783,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { // Session scope enforcement (before ACL) if path != "" && !strings.HasSuffix(path, "/") { - if !s.checkSessionScope(w, info, path, ip) { + if !s.checkSessionScope(w, r, info, path, ip) { return } } @@ -720,7 +805,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { // List mode: path is empty or ends with / if path == "" || strings.HasSuffix(path, "/") { // Session action enforcement: list - if !s.checkSessionAction(w, info, "list", path, ip) { + if !s.checkSessionAction(w, r, info, "list", path, ip) { return } allPaths, err := s.backend.List(path) @@ -740,7 +825,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { } visible = append(visible, p) } - s.auditAllowed(info, "list", path, ip) + s.auditAllowed(r, info, "list", path, ip) jsonOK(w, map[string]interface{}{"paths": visible}) return } @@ -749,28 +834,28 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { switch reason := s.dataAccessReason(info, path, "read_value", acl.ActionReadValue); reason { case "": case "session_scope": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "scope_check", path, ip, "session_scope", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "scope_check", path, ip, "session_scope", info.SessionID) jsonDenied(w, "access_denied", "SCOPE_EXCEEDED", fmt.Sprintf("path %q is outside session scope for role %q", path, info.SessionRole), "request a session with a role that includes this namespace", http.StatusForbidden) return case "session_action": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "read_value", path, ip, "session_action", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "read_value", path, ip, "session_action", info.SessionID) jsonDenied(w, "access_denied", "ACTION_DENIED", fmt.Sprintf("action %q is not permitted by session role %q", "read_value", info.SessionRole), "request a session with a role that includes this action", http.StatusForbidden) return default: - s.auditDenied(info, "read_value", path, ip, "acl") + s.auditDenied(r, info, "read_value", path, ip, "acl") jsonError(w, "access denied: read_value permission required (use phoenix exec for context-free secret injection)", http.StatusForbidden) return } // Attestation policy check (with validated seal key state) if err := s.attestFull(r, path, info, false, false, sealKeyValidated); err != nil { - s.auditDenied(info, "read_value", path, ip, "attestation") + s.auditDenied(r, info, "read_value", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -790,7 +875,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { return } - s.auditAllowedWithSeal(info, "read_value", path, ip, sealKey != nil) + s.auditAllowedWithSeal(r, info, "read_value", path, ip, sealKey != nil) if sealKey != nil { env, err := crypto.SealValue(path, "", secret.Value, sealKey) if err != nil { @@ -830,28 +915,28 @@ func (s *Server) handleSetSecret(w http.ResponseWriter, r *http.Request) { switch reason := s.dataAccessReason(info, path, "write", acl.ActionWrite); reason { case "": case "session_scope": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "scope_check", path, ip, "session_scope", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "scope_check", path, ip, "session_scope", info.SessionID) jsonDenied(w, "access_denied", "SCOPE_EXCEEDED", fmt.Sprintf("path %q is outside session scope for role %q", path, info.SessionRole), "request a session with a role that includes this namespace", http.StatusForbidden) return case "session_action": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "write", path, ip, "session_action", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "write", path, ip, "session_action", info.SessionID) jsonDenied(w, "access_denied", "ACTION_DENIED", fmt.Sprintf("action %q is not permitted by session role %q", "write", info.SessionRole), "request a session with a role that includes this action", http.StatusForbidden) return default: - s.auditDenied(info, "write", path, ip, "acl") + s.auditDenied(r, info, "write", path, ip, "acl") jsonError(w, "access denied", http.StatusForbidden) return } // Attestation policy check if err := s.attest(r, path, info, false); err != nil { - s.auditDenied(info, "write", path, ip, "attestation") + s.auditDenied(r, info, "write", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -883,7 +968,7 @@ func (s *Server) handleSetSecret(w http.ResponseWriter, r *http.Request) { return } - s.auditAllowed(info, "write", path, ip) + s.auditAllowed(r, info, "write", path, ip) jsonOK(w, map[string]string{"status": "ok", "path": path}) } @@ -899,28 +984,28 @@ func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) { switch reason := s.dataAccessReason(info, path, "delete", acl.ActionDelete); reason { case "": case "session_scope": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "scope_check", path, ip, "session_scope", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "scope_check", path, ip, "session_scope", info.SessionID) jsonDenied(w, "access_denied", "SCOPE_EXCEEDED", fmt.Sprintf("path %q is outside session scope for role %q", path, info.SessionRole), "request a session with a role that includes this namespace", http.StatusForbidden) return case "session_action": - s.logAudit(s.audit.LogSessionDenied(info.Agent, "delete", path, ip, "session_action", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "delete", path, ip, "session_action", info.SessionID) jsonDenied(w, "access_denied", "ACTION_DENIED", fmt.Sprintf("action %q is not permitted by session role %q", "delete", info.SessionRole), "request a session with a role that includes this action", http.StatusForbidden) return default: - s.auditDenied(info, "delete", path, ip, "acl") + s.auditDenied(r, info, "delete", path, ip, "acl") jsonError(w, "access denied", http.StatusForbidden) return } // Attestation policy check if err := s.attest(r, path, info, false); err != nil { - s.auditDenied(info, "delete", path, ip, "attestation") + s.auditDenied(r, info, "delete", path, ip, "attestation") jsonError(w, "attestation required: "+err.Error(), http.StatusForbidden) return } @@ -944,7 +1029,7 @@ func (s *Server) handleDeleteSecret(w http.ResponseWriter, r *http.Request) { return } - s.auditAllowed(info, "delete", path, ip) + s.auditAllowed(r, info, "delete", path, ip) jsonOK(w, map[string]string{"status": "ok", "path": path}) } @@ -1037,7 +1122,7 @@ func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { // Try update first; if agent doesn't exist, fall through to add err := s.acl.UpdateAgent(req.Name, req.Token, req.Permissions) if err == nil { - s.logAudit(s.audit.LogAllowed(agentName, "update-agent", req.Name, clientIP(r))) + s.auditLogAllowed(r, agentName, "update-agent", req.Name, clientIP(r)) jsonOK(w, map[string]string{"status": "ok", "agent": req.Name, "action": "updated"}) return } @@ -1059,7 +1144,7 @@ func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "create-agent", req.Name, clientIP(r))) + s.auditLogAllowed(r, agentName, "create-agent", req.Name, clientIP(r)) jsonOK(w, map[string]string{"status": "ok", "agent": req.Name}) } @@ -1112,7 +1197,7 @@ func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "delete-agent", target, clientIP(r))) + s.auditLogAllowed(r, agentName, "delete-agent", target, clientIP(r)) jsonOK(w, map[string]string{"status": "ok", "agent": target, "action": "deleted"}) } @@ -1158,7 +1243,7 @@ func (s *Server) handleIssueCert(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "issue-cert", req.AgentName, clientIP(r))) + s.auditLogAllowed(r, agentName, "issue-cert", req.AgentName, clientIP(r)) jsonOK(w, map[string]interface{}{ "status": "ok", "agent": req.AgentName, @@ -1234,7 +1319,7 @@ func (s *Server) handleRotateMaster(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "rotate-master", fmt.Sprintf("%d namespaces", rotated), ip)) + s.auditLogAllowed(r, agentName, "rotate-master", fmt.Sprintf("%d namespaces", rotated), ip) log.Printf("master key rotated by %s: %d namespaces re-wrapped", agentName, rotated) jsonOK(w, map[string]interface{}{ @@ -1361,7 +1446,7 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { for _, refStr := range req.Refs { path, err := ref.Parse(refStr) if err != nil { - s.auditDenied(info, "resolve", refStr, ip, "malformed_ref") + s.auditDenied(r, info, "resolve", refStr, ip, "malformed_ref") errors[refStr] = err.Error() continue } @@ -1370,22 +1455,22 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { switch reason := s.dataAccessReason(info, path, "read_value", acl.ActionReadValue); reason { case "": case "session_scope": - s.auditDenied(info, "resolve", path, ip, "session_scope") + s.auditDenied(r, info, "resolve", path, ip, "session_scope") errors[refStr] = "path outside session scope" continue case "session_action": - s.auditDenied(info, "resolve", path, ip, "session_action") + s.auditDenied(r, info, "resolve", path, ip, "session_action") errors[refStr] = "action read_value not permitted by session role" continue default: - s.auditDenied(info, "resolve", path, ip, "acl") + s.auditDenied(r, info, "resolve", path, ip, "acl") errors[refStr] = "access denied: read_value permission required" continue } // Attestation policy check (per-ref) if err := s.attestFull(r, path, info, nonceValidated, signatureVerified, sealKey != nil); err != nil { - s.auditDenied(info, "resolve", path, ip, "attestation") + s.auditDenied(r, info, "resolve", path, ip, "attestation") errors[refStr] = "attestation required" continue } @@ -1394,19 +1479,19 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { // Dry-run: verify path exists without returning the secret value. if _, err := s.backend.Get(path); err != nil { if err == store.ErrSecretNotFound { - s.auditDenied(info, "dry-resolve", path, ip, "not_found") + s.auditDenied(r, info, "dry-resolve", path, ip, "not_found") errors[refStr] = "secret not found" } else if err == store.ErrInvalidPath { - s.auditDenied(info, "dry-resolve", path, ip, "invalid_path") + s.auditDenied(r, info, "dry-resolve", path, ip, "invalid_path") errors[refStr] = "invalid path" } else { - s.auditDenied(info, "dry-resolve", path, ip, "internal_error") + s.auditDenied(r, info, "dry-resolve", path, ip, "internal_error") log.Printf("error dry-resolving %q for %s: %v", path, agentName, err) errors[refStr] = "internal error" } continue } - s.auditAllowed(info, "dry-resolve", path, ip) + s.auditAllowed(r, info, "dry-resolve", path, ip) values[refStr] = "ok" continue } @@ -1414,20 +1499,20 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { secret, err := s.backend.Get(path) if err != nil { if err == store.ErrSecretNotFound { - s.auditDenied(info, "resolve", path, ip, "not_found") + s.auditDenied(r, info, "resolve", path, ip, "not_found") errors[refStr] = "secret not found" } else if err == store.ErrInvalidPath { - s.auditDenied(info, "resolve", path, ip, "invalid_path") + s.auditDenied(r, info, "resolve", path, ip, "invalid_path") errors[refStr] = "invalid path" } else { - s.auditDenied(info, "resolve", path, ip, "internal_error") + s.auditDenied(r, info, "resolve", path, ip, "internal_error") log.Printf("error resolving %q for %s: %v", path, agentName, err) errors[refStr] = "internal error" } continue } - s.auditAllowedWithSeal(info, "resolve", path, ip, sealKey != nil) + s.auditAllowedWithSeal(r, info, "resolve", path, ip, sealKey != nil) if sealKey != nil { env, err := crypto.SealValue(path, refStr, secret.Value, sealKey) @@ -1538,7 +1623,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "revoke-cert", req.SerialNumber, clientIP(r))) + s.auditLogAllowed(r, agentName, "revoke-cert", req.SerialNumber, clientIP(r)) jsonOK(w, map[string]string{ "status": "ok", "serial_number": req.SerialNumber, @@ -1718,7 +1803,7 @@ func (s *Server) handleMintToken(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogAllowed(agentName, "mint-token", req.Agent, clientIP(r))) + s.auditLogAllowed(r, agentName, "mint-token", req.Agent, clientIP(r)) jsonOK(w, map[string]interface{}{ "token": tok, "agent": claims.Agent, @@ -1836,7 +1921,7 @@ func (s *Server) handleGenerateKeyPair(w http.ResponseWriter, r *http.Request) { if existing != "" { auditAction = "rotate-keypair" } - s.logAudit(s.audit.LogAllowed(agentName, auditAction, req.AgentName, ip)) + s.auditLogAllowed(r, agentName, auditAction, req.AgentName, ip) w.Header().Set("Cache-Control", "no-store") jsonOK(w, map[string]string{ @@ -1955,7 +2040,7 @@ func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("role %q does not exist", req.Role), "check available roles in server config", http.StatusNotFound) - s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "role_not_found")) + s.auditLogDenied(r, info.Agent, "session.mint", req.Role, ip, "role_not_found") return } @@ -1968,7 +2053,7 @@ func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("auth method %q is not in role's bootstrap_trust list", bootstrapMethod), fmt.Sprintf("role %q accepts: %v", req.Role, role.BootstrapTrust), http.StatusForbidden) - s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "bootstrap_failed")) + s.auditLogDenied(r, info.Agent, "session.mint", req.Role, ip, "bootstrap_failed") return } @@ -1979,7 +2064,7 @@ func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { reason, fmt.Sprintf("role %q requires attestation: %v", req.Role, role.Attestation), http.StatusForbidden) - s.logAudit(s.audit.LogDenied(info.Agent, "session.mint", req.Role, ip, "attestation_failed")) + s.auditLogDenied(r, info.Agent, "session.mint", req.Role, ip, "attestation_failed") return } } @@ -2025,7 +2110,7 @@ func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { jsonError(w, "internal error", http.StatusInternalServerError) return } - s.logAudit(s.audit.LogAllowed(info.Agent, "approval.created", req.Role, ip)) + s.auditLogAllowed(r, info.Agent, "approval.created", req.Role, ip) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusAccepted) // 202 json.NewEncoder(w).Encode(map[string]interface{}{ @@ -2051,7 +2136,7 @@ func (s *Server) handleSessionMint(w http.ResponseWriter, r *http.Request) { } // Audit log - s.logAudit(s.audit.LogAllowed(info.Agent, "session.mint.approved", req.Role, ip)) + s.auditLogAllowed(r, info.Agent, "session.mint.approved", req.Role, ip) w.Header().Set("Cache-Control", "no-store") jsonOK(w, map[string]interface{}{ @@ -2093,7 +2178,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("role %q no longer exists in server config", info.SessionRole), "contact your administrator", http.StatusForbidden) - s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "role_not_found", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "session.renew", info.SessionRole, ip, "role_not_found", info.SessionID) return } @@ -2111,7 +2196,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("elevated step-up session for role %q cannot be renewed without fresh approval", info.SessionRole), "mint a new session and complete step-up approval again", http.StatusForbidden) - s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "step_up_reapproval_required", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "session.renew", info.SessionRole, ip, "step_up_reapproval_required", info.SessionID) return } @@ -2120,7 +2205,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("original bootstrap method %q is no longer trusted for role %q", sess.BootstrapMethod, info.SessionRole), "mint a new session with a currently trusted auth method", http.StatusForbidden) - s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "bootstrap_failed", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "session.renew", info.SessionRole, ip, "bootstrap_failed", info.SessionID) return } @@ -2134,7 +2219,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { reason, fmt.Sprintf("role %q requires attestation: %v", info.SessionRole, role.Attestation), http.StatusForbidden) - s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "attestation_failed", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "session.renew", info.SessionRole, ip, "attestation_failed", info.SessionID) return } if slicesContains(role.Attestation, "cert_fingerprint") && sess.CertFingerprint != "" && info.CertFingerprint != sess.CertFingerprint { @@ -2142,7 +2227,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { "role requires the same client certificate fingerprint used when the session was minted", "renew using the original client certificate or mint a new session", http.StatusForbidden) - s.logAudit(s.audit.LogSessionDenied(info.Agent, "session.renew", info.SessionRole, ip, "cert_continuity_failed", info.SessionID)) + s.auditLogSessionDenied(r, info.Agent, "session.renew", info.SessionRole, ip, "cert_continuity_failed", info.SessionID) return } } @@ -2169,7 +2254,7 @@ func (s *Server) handleSessionRenew(w http.ResponseWriter, r *http.Request) { return } - s.logAudit(s.audit.LogSessionAllowed(info.Agent, "session.renewed", info.SessionRole, ip, info.SessionID)) + s.auditLogSessionAllowed(r, info.Agent, "session.renewed", info.SessionRole, ip, info.SessionID) w.Header().Set("Cache-Control", "no-store") jsonOK(w, map[string]interface{}{ @@ -2393,7 +2478,7 @@ func (s *Server) handleApprovalApprove(w http.ResponseWriter, r *http.Request, i valErr.Error(), "role config changed while approval was pending", status) - s.logAudit(s.audit.LogDenied(info.Agent, "approval.approve", apr.Role, ip, valErr.Error())) + s.auditLogDenied(r, info.Agent, "approval.approve", apr.Role, ip, valErr.Error()) return } @@ -2423,7 +2508,7 @@ func (s *Server) handleApprovalApprove(w http.ResponseWriter, r *http.Request, i return } - s.logAudit(s.audit.LogAllowed(info.Agent, "approval.approved", apr.Role, ip)) + s.auditLogAllowed(r, info.Agent, "approval.approved", apr.Role, ip) // TTY warning: compare requester and approver TTYs sameTTYWarning := false @@ -2473,7 +2558,7 @@ func (s *Server) handleApprovalDeny(w http.ResponseWriter, r *http.Request, id s return } - s.logAudit(s.audit.LogAllowed(info.Agent, "approval.denied", id, ip)) + s.auditLogAllowed(r, info.Agent, "approval.denied", id, ip) jsonOK(w, map[string]interface{}{ "id": id, @@ -2552,7 +2637,7 @@ func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) { return } jsonOK(w, map[string]interface{}{"sessions": []interface{}{sessionToMap(sess)}}) - s.auditAllowed(info, "session.list", "self", ip) + s.auditAllowed(r, info, "session.list", "self", ip) return } @@ -2591,7 +2676,7 @@ func (s *Server) handleSessionList(w http.ResponseWriter, r *http.Request) { } jsonOK(w, map[string]interface{}{"sessions": items}) - s.auditAllowed(info, "session.list", "", ip) + s.auditAllowed(r, info, "session.list", "", ip) } // handleSessionRouter dispatches /v1/sessions/{id} and /v1/sessions/{id}/revoke. @@ -2655,7 +2740,7 @@ func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request, id st } jsonOK(w, sessionToMap(sess)) - s.auditAllowed(info, "session.info", id, ip) + s.auditAllowed(r, info, "session.info", id, ip) } // handleSessionRevoke revokes a session by ID. @@ -2695,7 +2780,7 @@ func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request, id return } - s.auditAllowed(info, "session.revoke", id, ip) + s.auditAllowed(r, info, "session.revoke", id, ip) jsonOK(w, map[string]string{ "status": "revoked", "session_id": id, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 088eebd..e83ab2f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1078,6 +1078,235 @@ func TestResolveAuditsAllPaths(t *testing.T) { } } +type apiAuditEntry struct { + Agent string `json:"agent"` + Action string `json:"action"` + Path string `json:"path"` + Status string `json:"status"` + Reason string `json:"reason"` + Metadata map[string]string `json:"metadata"` +} + +func queryAPIAuditEntries(t *testing.T, srv *Server, adminToken string) ([]apiAuditEntry, string) { + t.Helper() + req := httptest.NewRequest("GET", "/v1/audit", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("audit query: expected 200, got %d: %s", w.Code, w.Body.String()) + } + raw := w.Body.String() + var auditResp struct { + Entries []apiAuditEntry `json:"entries"` + } + if err := json.Unmarshal([]byte(raw), &auditResp); err != nil { + t.Fatalf("decode audit response: %v", err) + } + return auditResp.Entries, raw +} + +func findAPIAuditEntry(entries []apiAuditEntry, action, path, status, reason string) *apiAuditEntry { + for i := len(entries) - 1; i >= 0; i-- { + e := &entries[i] + if e.Action != action || e.Path != path || e.Status != status { + continue + } + if reason != "" && e.Reason != reason { + continue + } + return e + } + return nil +} + +func addSpoofedOpenClawHeaders(req *http.Request, rawSessionKey string) { + req.Header.Set("X-OpenClaw-Agent", "admin\nspoof") + req.Header.Set("X-OpenClaw-Session-Id", " session-123 ") + req.Header.Set("X-OpenClaw-Channel", "growth\tops") + req.Header.Set("X-OpenClaw-Requester-Sender", "aaron@example") + req.Header.Set("X-OpenClaw-Sender-Is-Owner", "true") + req.Header.Set("X-OpenClaw-Session-Key", rawSessionKey) + req.Header.Set("X-OpenClaw-Role", "admin") +} + +func TestOpenClawHeadersCapturedAsSanitizedAuditMetadataOnly(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "secret-val"}) + req := httptest.NewRequest("PUT", "/v1/secrets/test/openclaw-meta", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + rawSessionKey := "ocsk_DO_NOT_LOG_12345" + req = httptest.NewRequest("GET", "/v1/secrets/test/openclaw-meta", nil) + req.Header.Set("Authorization", "Bearer reader-token") + addSpoofedOpenClawHeaders(req, rawSessionKey) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("read: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + entries, rawAudit := queryAPIAuditEntries(t, srv, adminToken) + if strings.Contains(rawAudit, rawSessionKey) { + t.Fatal("raw X-OpenClaw-Session-Key appeared in audit output") + } + + entry := findAPIAuditEntry(entries, "read_value", "test/openclaw-meta", "allowed", "") + if entry == nil { + t.Fatalf("missing read_value audit entry; got: %+v", entries) + } + if entry.Agent != "reader" { + t.Fatalf("audit agent = %q, want authenticated Phoenix agent %q", entry.Agent, "reader") + } + expected := map[string]string{ + "openclaw.agent": "admin spoof", + "openclaw.session_id": "session-123", + "openclaw.channel": "growth ops", + "openclaw.requester_sender": "aaron@example", + "openclaw.sender_is_owner": "true", + } + if len(entry.Metadata) != len(expected) { + t.Fatalf("metadata keys = %+v, want exactly %+v", entry.Metadata, expected) + } + for k, want := range expected { + if got := entry.Metadata[k]; got != want { + t.Fatalf("metadata[%s] = %q, want %q", k, got, want) + } + } + if _, ok := entry.Metadata["openclaw.session_key"]; ok { + t.Fatal("session key metadata must not be present") + } +} + +func TestOpenClawAgentHeaderDoesNotBypassACL(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "admin-secret"}) + req := httptest.NewRequest("PUT", "/v1/secrets/proxmox/admin-token", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("GET", "/v1/secrets/proxmox/admin-token", nil) + req.Header.Set("Authorization", "Bearer reader-token") + req.Header.Set("X-OpenClaw-Agent", "admin") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("spoofed OpenClaw agent should not bypass ACL: got %d: %s", w.Code, w.Body.String()) + } + + entries, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "proxmox/admin-token", "denied", "acl") + if entry == nil { + t.Fatalf("missing denied ACL audit entry; got: %+v", entries) + } + if entry.Agent != "reader" { + t.Fatalf("audit agent = %q, want authenticated Phoenix agent %q", entry.Agent, "reader") + } + if got := entry.Metadata["openclaw.agent"]; got != "admin" { + t.Fatalf("metadata openclaw.agent = %q, want spoofed hint %q", got, "admin") + } +} + +func TestOpenClawHeadersDoNotSatisfyAttestationPolicy(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "guarded"}) + req := httptest.NewRequest("PUT", "/v1/secrets/locked/openclaw", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + p, err := policy.Load([]byte(`{"attestation":{"locked/*":{"deny_bearer":true}}}`)) + if err != nil { + t.Fatalf("load policy: %v", err) + } + srv.SetPolicy(p) + + req = httptest.NewRequest("GET", "/v1/secrets/locked/openclaw", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("X-OpenClaw-Agent", "mtls-agent") + req.Header.Set("X-OpenClaw-Sender-Is-Owner", "true") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("OpenClaw headers should not satisfy deny_bearer policy: got %d: %s", w.Code, w.Body.String()) + } + + entries, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "locked/openclaw", "denied", "attestation") + if entry == nil { + t.Fatalf("missing attestation denial audit entry; got: %+v", entries) + } + if entry.Agent != "admin" { + t.Fatalf("audit agent = %q, want authenticated Phoenix agent %q", entry.Agent, "admin") + } + if got := entry.Metadata["openclaw.agent"]; got != "mtls-agent" { + t.Fatalf("metadata openclaw.agent = %q, want %q", got, "mtls-agent") + } +} + +func TestOpenClawSessionKeyDoesNotSatisfySealedPolicyOrLeak(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "sealed-required"}) + req := httptest.NewRequest("PUT", "/v1/secrets/sealed/openclaw", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + p, err := policy.Load([]byte(`{"attestation":{"sealed/*":{"require_sealed":true}}}`)) + if err != nil { + t.Fatalf("load policy: %v", err) + } + srv.SetPolicy(p) + + rawSessionKey := "ocsk_SEALED_POLICY_DO_NOT_LOG" + req = httptest.NewRequest("GET", "/v1/secrets/sealed/openclaw", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("X-OpenClaw-Session-Key", rawSessionKey) + req.Header.Set("X-OpenClaw-Session-Id", "session-for-audit") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("OpenClaw session key should not satisfy sealed policy: got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "sealed response required") { + t.Fatalf("expected sealed policy denial, got: %s", w.Body.String()) + } + + entries, rawAudit := queryAPIAuditEntries(t, srv, adminToken) + if strings.Contains(rawAudit, rawSessionKey) { + t.Fatal("raw X-OpenClaw-Session-Key appeared in audit output") + } + entry := findAPIAuditEntry(entries, "read_value", "sealed/openclaw", "denied", "attestation") + if entry == nil { + t.Fatalf("missing sealed policy audit entry; got: %+v", entries) + } + if got := entry.Metadata["openclaw.session_id"]; got != "session-for-audit" { + t.Fatalf("metadata openclaw.session_id = %q, want %q", got, "session-for-audit") + } + if _, ok := entry.Metadata["openclaw.session_key"]; ok { + t.Fatal("session key metadata must not be present") + } +} + func TestResolveEmptyRefs(t *testing.T) { srv, adminToken := setupTestServer(t) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 69fc171..fa64dbc 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -15,15 +15,16 @@ import ( // Entry is a single audit log record. type Entry struct { - Timestamp time.Time `json:"ts"` - Agent string `json:"agent"` - Action string `json:"action"` - Path string `json:"path"` - Status string `json:"status"` // "allowed" or "denied" - IP string `json:"ip,omitempty"` - Reason string `json:"reason,omitempty"` - SessionID string `json:"session_id,omitempty"` - Sealed bool `json:"sealed"` + Timestamp time.Time `json:"ts"` + Agent string `json:"agent"` + Action string `json:"action"` + Path string `json:"path"` + Status string `json:"status"` // "allowed" or "denied" + IP string `json:"ip,omitempty"` + Reason string `json:"reason,omitempty"` + SessionID string `json:"session_id,omitempty"` + Sealed bool `json:"sealed"` + Metadata map[string]string `json:"metadata,omitempty"` } // Logger writes audit entries to a file. @@ -56,6 +57,11 @@ func NewWriterLogger(w io.Writer) *Logger { // log writes a low-level audit entry. func (l *Logger) log(agent, action, path, status, ip, reason, sessionID string, sealed bool) error { + return l.logWithMetadata(agent, action, path, status, ip, reason, sessionID, sealed, nil) +} + +// logWithMetadata writes a low-level audit entry with optional sanitized metadata. +func (l *Logger) logWithMetadata(agent, action, path, status, ip, reason, sessionID string, sealed bool, metadata map[string]string) error { entry := Entry{ Timestamp: time.Now().UTC(), Agent: agent, @@ -66,6 +72,7 @@ func (l *Logger) log(agent, action, path, status, ip, reason, sessionID string, Reason: reason, SessionID: sessionID, Sealed: sealed, + Metadata: cloneMetadata(metadata), } l.mu.Lock() @@ -80,36 +87,87 @@ func (l *Logger) log(agent, action, path, status, ip, reason, sessionID string, return nil } +func cloneMetadata(metadata map[string]string) map[string]string { + if len(metadata) == 0 { + return nil + } + cloned := make(map[string]string, len(metadata)) + for k, v := range metadata { + if k == "" || v == "" { + continue + } + cloned[k] = v + } + if len(cloned) == 0 { + return nil + } + return cloned +} + // LogAllowed is a convenience for logging permitted plaintext actions. func (l *Logger) LogAllowed(agent, action, path, ip string) error { - return l.log(agent, action, path, "allowed", ip, "", "", false) + return l.LogAllowedWithMetadata(agent, action, path, ip, nil) +} + +// LogAllowedWithMetadata logs a permitted plaintext action with optional sanitized metadata. +func (l *Logger) LogAllowedWithMetadata(agent, action, path, ip string, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "allowed", ip, "", "", false, metadata) } // LogAllowedSealed is a convenience for logging permitted actions with response // seal state (true when the response was sealed). func (l *Logger) LogAllowedSealed(agent, action, path, ip string, sealed bool) error { - return l.log(agent, action, path, "allowed", ip, "", "", sealed) + return l.LogAllowedSealedWithMetadata(agent, action, path, ip, sealed, nil) +} + +// LogAllowedSealedWithMetadata logs a permitted action with response seal state +// and optional sanitized metadata. +func (l *Logger) LogAllowedSealedWithMetadata(agent, action, path, ip string, sealed bool, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "allowed", ip, "", "", sealed, metadata) } // LogDenied is a convenience for logging denied actions. func (l *Logger) LogDenied(agent, action, path, ip, reason string) error { - return l.log(agent, action, path, "denied", ip, reason, "", false) + return l.LogDeniedWithMetadata(agent, action, path, ip, reason, nil) +} + +// LogDeniedWithMetadata logs a denied action with optional sanitized metadata. +func (l *Logger) LogDeniedWithMetadata(agent, action, path, ip, reason string, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "denied", ip, reason, "", false, metadata) } // LogSessionAllowed logs a permitted action with session context. func (l *Logger) LogSessionAllowed(agent, action, path, ip, sessionID string) error { - return l.log(agent, action, path, "allowed", ip, "", sessionID, false) + return l.LogSessionAllowedWithMetadata(agent, action, path, ip, sessionID, nil) +} + +// LogSessionAllowedWithMetadata logs a permitted action with session context +// and optional sanitized metadata. +func (l *Logger) LogSessionAllowedWithMetadata(agent, action, path, ip, sessionID string, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "allowed", ip, "", sessionID, false, metadata) } // LogSessionAllowedSealed logs a permitted action with session context and response // seal state (true when the response was sealed). func (l *Logger) LogSessionAllowedSealed(agent, action, path, ip, sessionID string, sealed bool) error { - return l.log(agent, action, path, "allowed", ip, "", sessionID, sealed) + return l.LogSessionAllowedSealedWithMetadata(agent, action, path, ip, sessionID, sealed, nil) +} + +// LogSessionAllowedSealedWithMetadata logs a permitted action with session context, +// response seal state, and optional sanitized metadata. +func (l *Logger) LogSessionAllowedSealedWithMetadata(agent, action, path, ip, sessionID string, sealed bool, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "allowed", ip, "", sessionID, sealed, metadata) } // LogSessionDenied logs a denied action with session context. func (l *Logger) LogSessionDenied(agent, action, path, ip, reason, sessionID string) error { - return l.log(agent, action, path, "denied", ip, reason, sessionID, false) + return l.LogSessionDeniedWithMetadata(agent, action, path, ip, reason, sessionID, nil) +} + +// LogSessionDeniedWithMetadata logs a denied action with session context and +// optional sanitized metadata. +func (l *Logger) LogSessionDeniedWithMetadata(agent, action, path, ip, reason, sessionID string, metadata map[string]string) error { + return l.logWithMetadata(agent, action, path, "denied", ip, reason, sessionID, false, metadata) } // Close flushes and closes the audit log file. diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 03cdc5a..58bc15f 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -93,6 +93,27 @@ func TestLogFormatIncludesSealedField(t *testing.T) { } } +func TestLogFormatIncludesMetadataWhenPresent(t *testing.T) { + var buf bytes.Buffer + logger := NewWriterLogger(&buf) + + metadata := map[string]string{"openclaw.agent": "claw", "empty": ""} + logger.LogAllowedWithMetadata("vector", "read", "test/key", "10.0.0.1", metadata) + metadata["openclaw.agent"] = "mutated" + + line := strings.TrimSpace(buf.String()) + var entry Entry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("unmarshal log line: %v", err) + } + if entry.Metadata["openclaw.agent"] != "claw" { + t.Fatalf("metadata openclaw.agent = %q, want %q", entry.Metadata["openclaw.agent"], "claw") + } + if _, ok := entry.Metadata["empty"]; ok { + t.Fatal("empty metadata value should be omitted") + } +} + func TestQueryByPath(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "audit.log") diff --git a/internal/version/version.go b/internal/version/version.go index 86e152e..25d42f4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.15.0" +var Version = "0.15.1" From 5118ecb150f00caf58cb47e4bee5fb6e57550bd8 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 12 Jul 2026 20:09:42 -0400 Subject: [PATCH 04/11] fix(openclaw): clear exec-provider merge-review findings - Mint unsealed sessions when the home directory cannot be determined and no PHOENIX_SEAL_KEY is set, instead of failing session mint. - Short-circuit empty ids in the exec provider without a resolve call. - Reject trailing non-whitespace data after the stdin JSON request. - Strip Unicode format/bidi control characters (Cf) from OpenClaw audit metadata values. - Add tests for sealed-envelope decrypt, non-200 responses, trailing data, empty ids, missing-HOME mint, and Cf sanitization. - Bump version to 0.15.2 and add release notes. Co-Authored-By: Claude Fable 5 --- RELEASE_NOTES.md | 23 +++++ cmd/phoenix/main.go | 16 ++- cmd/phoenix/openclaw_exec_provider_test.go | 113 ++++++++++++++++++++- cmd/phoenix/session_test.go | 51 ++++++++++ internal/api/api.go | 4 + internal/api/api_test.go | 19 ++++ internal/version/version.go | 2 +- 7 files changed, 221 insertions(+), 7 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e0cbc9f..505b7df 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,28 @@ # Release Notes +## v0.15.2 (2026-07-12) + +### OpenClaw Exec Provider Merge-Review Fixes + +- Session mint with `PHOENIX_ROLE` no longer fails when the home directory + cannot be determined and no `PHOENIX_SEAL_KEY` is set; the CLI now mints an + unsealed session, matching the missing-key-file behavior. +- The exec provider short-circuits an empty `ids` list to an empty response + without calling the resolve API. +- The exec provider rejects trailing non-whitespace data after the stdin JSON + request object. +- Audit metadata sanitization now strips Unicode format/bidi control + characters (category Cf, e.g. U+202E) from `X-OpenClaw-*` header values. +- Added tests for the sealed-envelope decrypt branch, non-200 server + responses in the exec-provider path, missing-HOME session mint, and + format-control sanitization. +- Documented the three exec-provider auth modes (bootstrap token + role, + mTLS + role, pre-minted session token) and the invalid combinations, + added `HOME` to the recommended `passEnv` example, documented the + audit-only `X-OpenClaw-*` metadata headers, and noted that signed resolve + is not supported in the exec-provider path. `phoenix openclaw-exec-provider` + is now documented as the canonical command form. + ## v0.15.1 (2026-06-17) ### OpenClaw Audit Metadata diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index 9484ead..aab7f73 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -1059,6 +1059,9 @@ func parseOpenClawExecProviderRequest(r io.Reader) (*openClawExecProviderRequest if err := dec.Decode(&req); err != nil { return nil, fmt.Errorf("decoding OpenClaw exec provider request: %w", err) } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("OpenClaw exec provider request contains trailing data") + } if req.ProtocolVersion != 1 { return nil, fmt.Errorf("OpenClaw exec provider protocolVersion must be 1") } @@ -1100,6 +1103,14 @@ func cmdOpenClawExecProvider(args []string) error { return err } + if len(req.IDs) == 0 { + enc := json.NewEncoder(os.Stdout) + return enc.Encode(openClawExecProviderResponse{ + ProtocolVersion: 1, + Values: make(map[string]string), + }) + } + refs := make([]string, 0, len(req.IDs)) seenRefs := make(map[string]bool, len(req.IDs)) for _, id := range req.IDs { @@ -2362,10 +2373,11 @@ func ensureSealKey(role string) (*[32]byte, string, error) { return priv, crypto.EncodeSealKey(pub), nil } - // Check per-role key file + // Check per-role key file. If the home directory cannot be determined, + // treat it the same as no key file: mint without seal binding. home, err := os.UserHomeDir() if err != nil { - return nil, "", fmt.Errorf("cannot determine home directory: %w", err) + return nil, "", nil } keyDir := filepath.Join(home, ".phoenix") keyPath := filepath.Join(keyDir, "session-seal-"+role+".key") diff --git a/cmd/phoenix/openclaw_exec_provider_test.go b/cmd/phoenix/openclaw_exec_provider_test.go index f071b8b..7fc623d 100644 --- a/cmd/phoenix/openclaw_exec_provider_test.go +++ b/cmd/phoenix/openclaw_exec_provider_test.go @@ -6,8 +6,11 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "testing" + + "github.com/phoenixsec/phoenix/internal/crypto" ) func TestParseOpenClawExecProviderRequest(t *testing.T) { @@ -43,6 +46,101 @@ func TestParseOpenClawExecProviderRequestRejectsNonPhoenixProvider(t *testing.T) } } +func TestParseOpenClawExecProviderRequestRejectsTrailingData(t *testing.T) { + _, err := parseOpenClawExecProviderRequest(strings.NewReader(`{"protocolVersion":1,"provider":"phoenix","ids":["api/key"]}{"extra":true}`)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "trailing data") { + t.Fatalf("error = %v, want trailing data message", err) + } +} + +func TestParseOpenClawExecProviderRequestAllowsTrailingWhitespace(t *testing.T) { + _, err := parseOpenClawExecProviderRequest(strings.NewReader(`{"protocolVersion":1,"provider":"phoenix","ids":["api/key"]}` + "\n \n")) + if err != nil { + t.Fatalf("parseOpenClawExecProviderRequest error: %v", err) + } +} + +func TestCmdOpenClawExecProviderEmptyIDsSkipsAPICall(t *testing.T) { + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call to %s for empty ids", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + }, func() { + out := runOpenClawExecProviderWithStdin(t, `{"protocolVersion":1,"provider":"phoenix","ids":[]}`) + var resp openClawExecProviderResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode stdout: %v; stdout=%q", err, out) + } + if resp.ProtocolVersion != 1 { + t.Fatalf("protocolVersion = %d, want 1", resp.ProtocolVersion) + } + if resp.Values == nil || len(resp.Values) != 0 { + t.Fatalf("values = %#v, want empty map", resp.Values) + } + if len(resp.Errors) != 0 { + t.Fatalf("errors = %#v, want none", resp.Errors) + } + }) +} + +func TestCmdOpenClawExecProviderSealedValues(t *testing.T) { + kp, err := crypto.GenerateSealKeyPair() + if err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(t.TempDir(), "seal.key") + if err := os.WriteFile(keyPath, []byte(crypto.EncodeSealKey(&kp.PrivateKey)), 0600); err != nil { + t.Fatal(err) + } + + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + pub, err := crypto.DecodeSealKey(r.Header.Get("X-Phoenix-Seal-Key")) + if err != nil { + t.Errorf("decode request seal key: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + env, _ := crypto.SealValue("api/key", "phoenix://api/key", "sealed-secret", pub) + json.NewEncoder(w).Encode(map[string]interface{}{ + "sealed_values": map[string]interface{}{"phoenix://api/key": env}, + }) + }, func() { + t.Setenv("PHOENIX_ROLE", "") + t.Setenv("PHOENIX_SEAL_KEY", keyPath) + + out := runOpenClawExecProviderWithStdin(t, `{"protocolVersion":1,"provider":"phoenix","ids":["api/key"]}`) + var resp openClawExecProviderResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode stdout: %v; stdout=%q", err, out) + } + if got := resp.Values["api/key"]; got != "sealed-secret" { + t.Fatalf("api/key value = %q, want decrypted sealed value", got) + } + if len(resp.Errors) != 0 { + t.Fatalf("errors = %#v, want none", resp.Errors) + } + }) +} + +func TestCmdOpenClawExecProviderServerErrorFails(t *testing.T) { + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"access denied"}`)) + }, func() { + out, err := runOpenClawExecProviderCommandWithStdinResult(t, `{"protocolVersion":1,"provider":"phoenix","ids":["api/key"]}`, func() error { + return cmdOpenClawExecProvider(nil) + }) + if err == nil { + t.Fatalf("expected error for non-200 response, stdout=%q", out) + } + if strings.TrimSpace(out) != "" { + t.Fatalf("stdout = %q, want no output on server error", out) + } + }) +} + func TestCmdOpenClawExecProviderStdoutShape(t *testing.T) { withMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/resolve" { @@ -134,6 +232,16 @@ func runOpenClawExecProviderWithStdin(t *testing.T, stdin string) string { func runOpenClawExecProviderCommandWithStdin(t *testing.T, stdin string, run func() error) string { t.Helper() + out, cmdErr := runOpenClawExecProviderCommandWithStdinResult(t, stdin, run) + if cmdErr != nil { + t.Fatalf("cmdOpenClawExecProvider error: %v", cmdErr) + } + return out +} + +func runOpenClawExecProviderCommandWithStdinResult(t *testing.T, stdin string, run func() error) (string, error) { + t.Helper() + origStdin := os.Stdin r, w, err := os.Pipe() if err != nil { @@ -155,8 +263,5 @@ func runOpenClawExecProviderCommandWithStdin(t *testing.T, stdin string, run fun out := captureStdout(t, func() { cmdErr = run() }) - if cmdErr != nil { - t.Fatalf("cmdOpenClawExecProvider error: %v", cmdErr) - } - return out + return out, cmdErr } diff --git a/cmd/phoenix/session_test.go b/cmd/phoenix/session_test.go index 00546cd..40a7ff9 100644 --- a/cmd/phoenix/session_test.go +++ b/cmd/phoenix/session_test.go @@ -114,6 +114,57 @@ func TestAutoMintSessionRenewsNearExpiryTokenInsteadOfMinting(t *testing.T) { }) } +func TestAutoMintSessionSucceedsUnsealedWithoutHome(t *testing.T) { + role := "deploy-role" + + withMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/session/mint" { + t.Errorf("unexpected request path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode mint body: %v", err) + } + if _, ok := body["seal_public_key"]; ok { + t.Errorf("expected no seal_public_key without HOME, got %q", body["seal_public_key"]) + } + resp := map[string]string{ + "session_token": "phxs_unsealed", + "expires_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }, func() { + t.Setenv("PHOENIX_ROLE", role) + t.Setenv("PHOENIX_SEAL_KEY", "") + t.Setenv("HOME", "") + t.Setenv("PHOENIX_TOKEN", "bootstrap-token") + token = "bootstrap-token" + + if err := requireAuth(); err != nil { + t.Fatalf("requireAuth without HOME: %v", err) + } + if token != "phxs_unsealed" { + t.Fatalf("token = %q, want minted session token", token) + } + }) +} + +func TestEnsureSealKeyMissingHomeReturnsUnsealed(t *testing.T) { + t.Setenv("PHOENIX_SEAL_KEY", "") + t.Setenv("HOME", "") + + priv, pub, err := ensureSealKey("deploy-role") + if err != nil { + t.Fatalf("ensureSealKey without HOME: %v", err) + } + if priv != nil || pub != "" { + t.Fatalf("expected unsealed result, got priv=%v pub=%q", priv, pub) + } +} + func TestCmdGetUsesRoleSessionSealKey(t *testing.T) { role := "deploy-role" kp, err := crypto.GenerateSealKeyPair() diff --git a/internal/api/api.go b/internal/api/api.go index 805f9ce..3c3f912 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -17,6 +17,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/phoenixsec/phoenix/internal/acl" "github.com/phoenixsec/phoenix/internal/approval" @@ -508,6 +509,9 @@ func sanitizeOpenClawAuditMetadataValue(value string) string { if r < 0x20 || r == 0x7f { return ' ' } + if unicode.Is(unicode.Cf, r) { + return -1 + } return r }, value) value = strings.Join(strings.Fields(value), " ") diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e83ab2f..31e4993 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1130,6 +1130,25 @@ func addSpoofedOpenClawHeaders(req *http.Request, rawSessionKey string) { req.Header.Set("X-OpenClaw-Role", "admin") } +func TestSanitizeOpenClawAuditMetadataValueStripsFormatControls(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "rtl-override", in: "admin‮nimda", want: "adminnimda"}, + {name: "zero-width-space-joiner", in: "ag​ent‍", want: "agent"}, + {name: "bidi-isolates", in: "⁦spoof⁩", want: "spoof"}, + {name: "only-format-chars", in: "‮​", want: ""}, + {name: "plain", in: "openclaw-agent", want: "openclaw-agent"}, + } + for _, tc := range cases { + if got := sanitizeOpenClawAuditMetadataValue(tc.in); got != tc.want { + t.Errorf("%s: sanitize(%q) = %q, want %q", tc.name, tc.in, got, tc.want) + } + } +} + func TestOpenClawHeadersCapturedAsSanitizedAuditMetadataOnly(t *testing.T) { srv, adminToken := setupTestServer(t) diff --git a/internal/version/version.go b/internal/version/version.go index 25d42f4..c8f90c3 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,4 @@ // go build -ldflags "-X github.com/phoenixsec/phoenix/internal/version.Version=1.0.0" package version -var Version = "0.15.1" +var Version = "0.15.2" From b7705ad31f239fcbaa2060d4901d4b16469ab320 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 12 Jul 2026 20:09:52 -0400 Subject: [PATCH 05/11] docs(openclaw): auth modes, passEnv HOME, audit-header documentation - Restructure exec-provider auth guidance into three explicit modes (bootstrap token + role, mTLS + role, pre-minted session token) and call out the invalid PHOENIX_ROLE + phxs_ combination. - Add HOME to the recommended passEnv example for per-role seal keys. - Document the audit-only X-OpenClaw-* metadata headers and the deliberate exclusion of X-OpenClaw-Session-Key. - Note signed resolve is unsupported in the exec-provider path and document phoenix openclaw-exec-provider as the canonical command. - Update stale roadmap shipped-version heading. Co-Authored-By: Claude Fable 5 --- docs/authentication.md | 5 +++ docs/integrations.md | 72 +++++++++++++++++++++++++++++++++++------- docs/roadmap.md | 2 +- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 376b24b..2a3c52e 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -63,6 +63,11 @@ Session tokens use a `phxs_` prefix and are scoped to specific namespaces and actions. Non-elevated sessions auto-renew, and sessions can be revoked individually. Elevated step-up sessions require fresh approval to extend. +Do not combine `PHOENIX_ROLE` with a pre-minted `phxs_` session token in +`PHOENIX_TOKEN`: role mode always re-mints a session, and a session token is +rejected as bootstrap auth. Use a bootstrap token (or mTLS) with +`PHOENIX_ROLE`, or a `phxs_` token alone without `PHOENIX_ROLE`. + Auth priority order: session token > mTLS > short-lived token > bearer. See [Session Identity](session-identity.md) for full details on roles, diff --git a/docs/integrations.md b/docs/integrations.md index 4b7e506..a009cc5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -60,10 +60,10 @@ startup configuration. Phoenix provides an OpenClaw-compatible exec provider command: ```bash -phoenix resolve --stdin-json -# equivalent aliases: phoenix openclaw-exec-provider +# equivalent aliases: phoenix secret-provider openclaw +phoenix resolve --stdin-json ``` This command reads OpenClaw's exec-provider JSON request from stdin: @@ -91,8 +91,9 @@ OpenClaw process: phoenix: { source: "exec", command: "/usr/local/bin/phoenix", - args: ["resolve", "--stdin-json"], + args: ["openclaw-exec-provider"], passEnv: [ + "HOME", "PHOENIX_SERVER", "PHOENIX_TOKEN", "PHOENIX_ROLE", @@ -112,6 +113,9 @@ OpenClaw process: } ``` +Passing `HOME` lets the CLI find per-role session seal keys under `~/.phoenix/` +(optional — without it the CLI mints unsealed sessions — but recommended). + Then use OpenClaw SecretRef objects in fields that support secrets: ```json5 @@ -133,23 +137,69 @@ The exec provider accepts ids either as Phoenix paths (`openclaw/shared/key`) or full refs (`phoenix://openclaw/shared/key`). It normalizes paths to Phoenix refs for resolution, but the stdout `values`/`errors` keys match OpenClaw's input ids. -Set Phoenix credentials for the OpenClaw process. Prefer scoped role/session or -mTLS credentials over a broad admin token: +Set Phoenix credentials for the OpenClaw process. There are three valid auth +modes — pick exactly one: + +**(a) Role auto-mint with a bootstrap token** — the CLI uses the bootstrap token +to mint a short-lived session for the role: + +```bash +export PHOENIX_SERVER=https://phoenix:9090 +export PHOENIX_TOKEN= # NOT a phxs_... session token +export PHOENIX_ROLE=openclaw-gateway +``` + +**(b) Role auto-mint with mTLS** — the client certificate is the bootstrap +identity; no bearer token needed: ```bash export PHOENIX_SERVER=https://phoenix:9090 -export PHOENIX_TOKEN= export PHOENIX_ROLE=openclaw-gateway -# Or use mTLS: export PHOENIX_CA_CERT=/etc/phoenix/ca.crt export PHOENIX_CLIENT_CERT=/etc/phoenix/openclaw.crt export PHOENIX_CLIENT_KEY=/etc/phoenix/openclaw.key ``` -`PHOENIX_ROLE`, mTLS, sealed responses, session auto-mint/renewal, and Phoenix -attestation headers are handled by the same CLI auth paths as `phoenix resolve`. -Plain `phoenix resolve ` remains the general human/script command; use -`phoenix resolve --stdin-json` for OpenClaw's stdin/stdout provider protocol. +**(c) Pre-minted session token** — mint a session out of band and hand the +`phxs_...` token to the process directly: + +```bash +export PHOENIX_SERVER=https://phoenix:9090 +export PHOENIX_TOKEN= +# Do NOT set PHOENIX_ROLE in this mode. +``` + +Combinations that do not work: + +- `PHOENIX_ROLE` + `PHOENIX_TOKEN=phxs_...` — role mode always re-mints a + session, and a session token is rejected as bootstrap auth. +- A `phxs_...` session token past its expiry — pre-minted tokens are not + renewed by the exec provider; use role auto-mint for long-running processes. + +Sealed responses, session auto-mint/renewal, and Phoenix attestation headers are +handled by the same CLI auth paths as `phoenix resolve`. Plain +`phoenix resolve ` remains the general human/script command; use +`phoenix openclaw-exec-provider` for OpenClaw's stdin/stdout provider protocol. +Signed resolve (`phoenix resolve --signed` challenge/response) is a CLI-path +feature and is not supported in the exec-provider path — bootstrap attestation +there relies on mTLS/role identity instead. + +### Audit-only OpenClaw metadata headers + +The Phoenix server captures the following request headers as audit-only, +untrusted metadata hints (each value sanitized and capped at 256 characters): + +- `X-OpenClaw-Agent` +- `X-OpenClaw-Session-Id` +- `X-OpenClaw-Channel` +- `X-OpenClaw-Requester-Sender` +- `X-OpenClaw-Sender-Is-Owner` + +They exist purely for audit-log correlation with OpenClaw sessions. They have +zero effect on authorization, attestation, or sealed-response decisions — +spoofing them cannot elevate access; the audited identity is always the +authenticated Phoenix agent. `X-OpenClaw-Session-Key` is deliberately not +captured and is never written to audit logs. ### 2. Plugin tools for agent/tool runtime access diff --git a/docs/roadmap.md b/docs/roadmap.md index 5ff9356..683dd44 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,7 +3,7 @@ > This roadmap is directional and may change based on user feedback. > It is not a contractual delivery commitment. -## Shipped (v0.13) +## Shipped (through v0.15) - Encrypted secret storage (AES-256-GCM envelope encryption) - Per-agent ACL with namespace isolation and glob matching From ef3d26db38e6d4d86c410093f63684cf8e872b52 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 12 Jul 2026 22:25:51 -0400 Subject: [PATCH 06/11] docs(openclaw): note provider key name and step-up role caveats Co-Authored-By: Claude Fable 5 --- docs/integrations.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/integrations.md b/docs/integrations.md index a009cc5..75e66f9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -116,6 +116,9 @@ OpenClaw process: Passing `HOME` lets the CLI find per-role session seal keys under `~/.phoenix/` (optional — without it the CLI mints unsealed sessions — but recommended). +The provider config key must be named exactly `phoenix` — the Phoenix CLI +validates the `provider` field it receives and rejects any other name. + Then use OpenClaw SecretRef objects in fields that support secrets: ```json5 @@ -176,6 +179,10 @@ Combinations that do not work: - A `phxs_...` session token past its expiry — pre-minted tokens are not renewed by the exec provider; use role auto-mint for long-running processes. +Do not point the exec provider at a role that requires step-up approval — the +CLI will block waiting for approval until OpenClaw's `timeoutMs` kills the +process; use a non-step-up role for bootstrap secrets. + Sealed responses, session auto-mint/renewal, and Phoenix attestation headers are handled by the same CLI auth paths as `phoenix resolve`. Plain `phoenix resolve ` remains the general human/script command; use From 8510c6cfcff65a89d32325c272216224637b3525 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 19 Jul 2026 01:21:36 -0400 Subject: [PATCH 07/11] test(e2e): recover and extend end-to-end harness --- .gitignore | 10 ++ tests/e2e/README.md | 48 ++++++ tests/e2e/docker-compose.e2e.yml | 34 +++++ tests/e2e/fixtures/acl-multi-agent.json | 14 ++ tests/e2e/fixtures/docker-compose.yml | 24 +++ tests/e2e/fixtures/flask-app/app.py | 19 +++ tests/e2e/fixtures/policy-graduated.json | 14 ++ tests/e2e/fixtures/policy-nonce.json | 8 + tests/e2e/fixtures/policy-time-window.json | 8 + tests/e2e/fixtures/sample.env | 2 + tests/e2e/lib.sh | 137 ++++++++++++++++++ tests/e2e/run-all.sh | 94 ++++++++++++ tests/e2e/scenario-01-lifecycle.sh | 16 ++ tests/e2e/scenario-02-multi-agent.sh | 15 ++ tests/e2e/scenario-03-attestation.sh | 17 +++ tests/e2e/scenario-04-exec.sh | 14 ++ tests/e2e/scenario-05-rotation.sh | 17 +++ tests/e2e/scenario-06-certs.sh | 20 +++ tests/e2e/scenario-07-time-nonce.sh | 23 +++ tests/e2e/scenario-08-short-lived.sh | 16 ++ tests/e2e/scenario-09-postgres.sh | 25 ++++ tests/e2e/scenario-10-mcp.sh | 25 ++++ tests/e2e/scenario-11-zero-plaintext.sh | 23 +++ tests/e2e/scenario-12-1password.sh | 5 + .../e2e/scenario-13-openclaw-exec-provider.sh | 24 +++ tests/e2e/scenario-14-auth-modes.sh | 32 ++++ tests/e2e/scenario-15-audit-metadata.sh | 24 +++ tests/e2e/scenario-16-stepup.sh | 25 ++++ tests/e2e/scenario-17-sealed.sh | 32 ++++ tests/e2e/scenario-18-upgrade.sh | 48 ++++++ tests/e2e/stack.sh | 15 ++ 31 files changed, 828 insertions(+) create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/docker-compose.e2e.yml create mode 100644 tests/e2e/fixtures/acl-multi-agent.json create mode 100644 tests/e2e/fixtures/docker-compose.yml create mode 100644 tests/e2e/fixtures/flask-app/app.py create mode 100644 tests/e2e/fixtures/policy-graduated.json create mode 100644 tests/e2e/fixtures/policy-nonce.json create mode 100644 tests/e2e/fixtures/policy-time-window.json create mode 100644 tests/e2e/fixtures/sample.env create mode 100755 tests/e2e/lib.sh create mode 100755 tests/e2e/run-all.sh create mode 100755 tests/e2e/scenario-01-lifecycle.sh create mode 100755 tests/e2e/scenario-02-multi-agent.sh create mode 100755 tests/e2e/scenario-03-attestation.sh create mode 100755 tests/e2e/scenario-04-exec.sh create mode 100755 tests/e2e/scenario-05-rotation.sh create mode 100755 tests/e2e/scenario-06-certs.sh create mode 100755 tests/e2e/scenario-07-time-nonce.sh create mode 100755 tests/e2e/scenario-08-short-lived.sh create mode 100755 tests/e2e/scenario-09-postgres.sh create mode 100755 tests/e2e/scenario-10-mcp.sh create mode 100755 tests/e2e/scenario-11-zero-plaintext.sh create mode 100755 tests/e2e/scenario-12-1password.sh create mode 100755 tests/e2e/scenario-13-openclaw-exec-provider.sh create mode 100755 tests/e2e/scenario-14-auth-modes.sh create mode 100755 tests/e2e/scenario-15-audit-metadata.sh create mode 100755 tests/e2e/scenario-16-stepup.sh create mode 100755 tests/e2e/scenario-17-sealed.sh create mode 100755 tests/e2e/scenario-18-upgrade.sh create mode 100755 tests/e2e/stack.sh diff --git a/.gitignore b/.gitignore index 1bf3391..3c8f507 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,16 @@ __pycache__/ # Node node_modules/ +# E2E runtime artifacts +/tests/e2e/.bin/ +/tests/e2e/logs/ +/tests/e2e/results/ +/tests/e2e/*.jsonl +/tests/e2e/*.log +/tests/e2e/*.out +/tests/e2e/*.pid +/tests/e2e/*.tap + # Local agent guidance (private) /AGENTS.md /CLAUDE.md diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..77161f4 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,48 @@ +# Phoenix end-to-end harness + +The canonical Phoenix e2e suite. It runs only throwaway state, localhost-bound services, and synthetic values. Run the full matrix in an isolated test environment. + +## Run + +```bash +./tests/e2e/run-all.sh --list +./tests/e2e/run-all.sh --scenarios 01,13,17 +./tests/e2e/run-all.sh --format jsonl # or: --format tap +``` + +The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PATH Go executable, `E2E_BIN_DIR` to reuse prebuilt binaries, or `E2E_REBUILD=1` to force a rebuild. Scenario 09 additionally needs Docker Compose and `psql`; it chooses a per-run project and free localhost port. Manual `stack.sh` use can override `COMPOSE_PROJECT_NAME` and the `E2E_*_PORT` variables. Scenario 18 builds Git commit `21a6ca4` (v0.13.5) and the current checkout. + +## Add a scenario + +1. Add one executable `scenario-NN-name.sh`; do not edit a registry. +2. Source `lib.sh`, call `scenario_start`, and use a unique `init_scenario NN`. +3. Keep setup, assertions, and teardown in that file; shared mechanics belong in `lib.sh`. +4. Confirm discovery with `./run-all.sh --list`. +5. Confirm isolated execution with `./run-all.sh --scenarios NN --format jsonl`. + +## Conventions for agents + +- Exit `0` for pass, `77` for an explicit environmental skip, and nonzero for failure. +- Never use real secrets, production Phoenix, or production credentials. Values must be visibly synthetic. +- Bind listeners and fixture ports to localhost only. +- No prompts or manual approval steps; drive approval APIs programmatically. +- Every scenario must be repeatable and clean up its process, temporary state, and containers. +- Do not print tokens or resolved values except synthetic expected values in controlled assertions. +- Machine consumers should use JSON Lines or TAP rather than scrape colored human logs. + +## Inventory + +| ID | Coverage | Status | +|---:|---|---| +| 01–04 | lifecycle, ACL isolation, attestation, exec stripping | implemented | +| 05–08 | rotation under load, cert lifecycle, time/nonce, short-lived tokens | implemented | +| 09–11 | PostgreSQL rotation, MCP stdio, zero-plaintext config | implemented | +| 12 | 1Password bridge | deferred: requires a separately authorized synthetic vault; exits 77 | +| 13 | OpenClaw exec-provider protocol and edge cases | implemented | +| 14 | bearer-role, mTLS-role, pre-minted session, invalid auth combinations | implemented | +| 15 | sanitized audit metadata and spoof resistance | implemented | +| 16 | elevated step-up deny/approve/expiry/renewal | implemented | +| 17 | sealed-response S1–S8 plus policy/keypair regressions | implemented | +| 18 | v0.13.5 → current in-place data/config/ACL/audit upgrade | implemented | + +Active sessions are intentionally memory-only. Scenario 18 verifies that an old in-memory token is rejected after restart and that the preserved role configuration can mint a new compatible session. diff --git a/tests/e2e/docker-compose.e2e.yml b/tests/e2e/docker-compose.e2e.yml new file mode 100644 index 0000000..5d818a9 --- /dev/null +++ b/tests/e2e/docker-compose.e2e.yml @@ -0,0 +1,34 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: synthetic-pg-initial + ports: + - "127.0.0.1:${E2E_POSTGRES_PORT:-15432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"] + interval: 2s + timeout: 2s + retries: 30 + + redis: + image: redis:7 + command: ["redis-server", "--requirepass", "synthetic-redis"] + ports: + - "127.0.0.1:${E2E_REDIS_PORT:-16379}:6379" + + miniflux: + image: miniflux/miniflux:2.2.13 + environment: + DATABASE_URL: postgres://testuser:synthetic-pg-initial@postgres/testdb?sslmode=disable + RUN_MIGRATIONS: "1" + CREATE_ADMIN: "1" + ADMIN_USERNAME: synthetic-admin + ADMIN_PASSWORD: synthetic-miniflux + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:${E2E_MINIFLUX_PORT:-18090}:8080" diff --git a/tests/e2e/fixtures/acl-multi-agent.json b/tests/e2e/fixtures/acl-multi-agent.json new file mode 100644 index 0000000..3771e34 --- /dev/null +++ b/tests/e2e/fixtures/acl-multi-agent.json @@ -0,0 +1,14 @@ +{ + "agents": { + "fixture-reader": { + "name": "fixture-reader", + "token_hash": "sha256:5ooACvB0FsN+N9FP4HliWeRKeXy9nC5eEzqEAblgkZw=", + "permissions": [ + { + "path": "fixture/*", + "actions": ["list", "read_value"] + } + ] + } + } +} diff --git a/tests/e2e/fixtures/docker-compose.yml b/tests/e2e/fixtures/docker-compose.yml new file mode 100644 index 0000000..476b551 --- /dev/null +++ b/tests/e2e/fixtures/docker-compose.yml @@ -0,0 +1,24 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: synthetic-pg-fixture + ports: ["127.0.0.1:15432:5432"] + + redis: + image: redis:7 + command: ["redis-server", "--requirepass", "synthetic-redis-fixture"] + ports: ["127.0.0.1:16379:6379"] + + miniflux: + image: miniflux/miniflux:2.2.13 + environment: + DATABASE_URL: postgres://testuser:synthetic-pg-fixture@postgres/testdb?sslmode=disable + RUN_MIGRATIONS: "1" + CREATE_ADMIN: "1" + ADMIN_USERNAME: synthetic-admin + ADMIN_PASSWORD: synthetic-miniflux-fixture + depends_on: [postgres] + ports: ["127.0.0.1:18090:8080"] diff --git a/tests/e2e/fixtures/flask-app/app.py b/tests/e2e/fixtures/flask-app/app.py new file mode 100644 index 0000000..7ed2982 --- /dev/null +++ b/tests/e2e/fixtures/flask-app/app.py @@ -0,0 +1,19 @@ +from flask import Flask, request, jsonify +import os + +app = Flask(__name__) +EXPECTED = os.getenv("APP_API_KEY", "dev-key") + +@app.get("/health") +def health(): + return {"ok": True} + +@app.get("/protected") +def protected(): + provided = request.headers.get("X-API-Key", "") + if provided != EXPECTED: + return jsonify({"ok": False, "error": "unauthorized"}), 401 + return jsonify({"ok": True, "message": "authorized"}) + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5000) diff --git a/tests/e2e/fixtures/policy-graduated.json b/tests/e2e/fixtures/policy-graduated.json new file mode 100644 index 0000000..9eeff1c --- /dev/null +++ b/tests/e2e/fixtures/policy-graduated.json @@ -0,0 +1,14 @@ +{ + "attestation": { + "development/*": {}, + "staging/*": { + "require_mtls": true, + "source_ip": ["127.0.0.1", "::1"] + }, + "production/*": { + "require_mtls": true, + "deny_bearer": true, + "require_sealed": true + } + } +} diff --git a/tests/e2e/fixtures/policy-nonce.json b/tests/e2e/fixtures/policy-nonce.json new file mode 100644 index 0000000..c65b67f --- /dev/null +++ b/tests/e2e/fixtures/policy-nonce.json @@ -0,0 +1,8 @@ +{ + "attestation": { + "high-security/*": { + "require_nonce": true, + "nonce_max_age": "30s" + } + } +} diff --git a/tests/e2e/fixtures/policy-time-window.json b/tests/e2e/fixtures/policy-time-window.json new file mode 100644 index 0000000..10c3ba7 --- /dev/null +++ b/tests/e2e/fixtures/policy-time-window.json @@ -0,0 +1,8 @@ +{ + "attestation": { + "business-hours/*": { + "time_window": "09:00-17:00", + "time_zone": "UTC" + } + } +} diff --git a/tests/e2e/fixtures/sample.env b/tests/e2e/fixtures/sample.env new file mode 100644 index 0000000..1d87bd5 --- /dev/null +++ b/tests/e2e/fixtures/sample.env @@ -0,0 +1,2 @@ +EXAMPLE_ONE=synthetic-value-one +EXAMPLE_TWO=synthetic-value-two diff --git a/tests/e2e/lib.sh b/tests/e2e/lib.sh new file mode 100755 index 0000000..8ea57ec --- /dev/null +++ b/tests/e2e/lib.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$E2E_DIR" rev-parse --show-toplevel)" +E2E_BIN_DIR="${E2E_BIN_DIR:-${TMPDIR:-/tmp}/phoenix-e2e-bin-$(id -u)}" +GO_BIN="${GO_BIN:-$(command -v go 2>/dev/null || true)}" +PHOENIX="$E2E_BIN_DIR/phoenix" +PHOENIX_SERVER_BIN="$E2E_BIN_DIR/phoenix-server" + +# Scenarios opt into auth inputs explicitly. Never inherit a developer's live +# Phoenix role, token, certificate, seal key, or policy configuration. +unset PHOENIX_TOKEN PHOENIX_ROLE PHOENIX_CLIENT_CERT PHOENIX_CLIENT_KEY +unset PHOENIX_CA_CERT PHOENIX_SEAL_KEY PHOENIX_POLICY + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[1;34m'; NC='\033[0m' +log() { printf "${BLUE}[INFO]${NC} %s\n" "$*"; } +ok() { printf "${GREEN}[OK]${NC} %s\n" "$*"; } +warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; } +fail() { printf "${RED}[FAIL]${NC} %s\n" "$*" >&2; return 1; } +die() { fail "$*"; exit 1; } +skip() { warn "SKIP: $*"; exit 77; } +scenario_start() { log "Scenario $1: $2"; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || skip "$1 is not installed"; } +assert_eq() { [[ "$1" == "$2" ]] || die "${3:-values differ}: got '$1', want '$2'"; } +assert_contains() { [[ "$1" == *"$2"* ]] || die "${3:-output missing expected text}: $2"; } +assert_not_contains() { [[ "$1" != *"$2"* ]] || die "${3:-output contains forbidden text}: $2"; } + +build_current() { + [[ -n "$GO_BIN" && -x "$GO_BIN" ]] || die "Go is not available; set GO_BIN to the Go executable" + mkdir -p "$E2E_BIN_DIR" + if [[ ! -x "$PHOENIX" || ! -x "$PHOENIX_SERVER_BIN" || "${E2E_REBUILD:-0}" == 1 ]]; then + log "Building current Phoenix binaries into $E2E_BIN_DIR" + (cd "$REPO_ROOT" && "$GO_BIN" build -o "$E2E_BIN_DIR/" ./cmd/...) + fi +} + +free_port() { + python3 - <<'PY' +import socket +with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + print(s.getsockname()[1]) +PY +} + +init_scenario() { + local id="$1" port="${2:-}" + require_cmd python3 + require_cmd curl + require_cmd jq + [[ -n "$port" ]] || port="$(free_port)" + build_current + RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/phoenix-e2e-s${id}.XXXXXX")" + SERVER_PID="" + "$PHOENIX_SERVER_BIN" --init "$RUN_DIR" >"$RUN_DIR/init.out" + ADMIN_TOKEN="$(python3 - "$RUN_DIR/init.out" <<'PY' +import re, sys +s=open(sys.argv[1], encoding='utf-8').read() +m=re.search(r'ADMIN TOKEN.*?\n([a-f0-9]{32,})', s, re.S) +print(m.group(1) if m else '') +PY +)" + [[ -n "$ADMIN_TOKEN" ]] || die "could not parse generated admin token" + python3 - "$RUN_DIR/config.json" "$port" <<'PY' +import json, sys +p=sys.argv[1]; d=json.load(open(p, encoding='utf-8')) +d['server']['listen']='127.0.0.1:'+sys.argv[2] +json.dump(d, open(p,'w', encoding='utf-8'), indent=2) +PY + PHOENIX_SERVER="http://127.0.0.1:$port" + export RUN_DIR ADMIN_TOKEN PHOENIX_SERVER + trap cleanup_scenario EXIT INT TERM +} + +config_patch() { + local python_body="$1" + PYTHON_BODY="$python_body" python3 - "$RUN_DIR/config.json" <<'PY' +import json, os, sys +p=sys.argv[1]; d=json.load(open(p, encoding='utf-8')) +exec(os.environ['PYTHON_BODY'], {'d': d}) +json.dump(d, open(p,'w', encoding='utf-8'), indent=2) +PY +} + +start_server() { + "$PHOENIX_SERVER_BIN" --config "$RUN_DIR/config.json" >"$RUN_DIR/server.log" 2>&1 & + SERVER_PID=$! + local i + local -a health_args=(-fsS) + [[ "$PHOENIX_SERVER" != https://* ]] || health_args+=(--cacert "$RUN_DIR/ca.crt") + for i in $(seq 1 100); do + if curl "${health_args[@]}" "$PHOENIX_SERVER/v1/health" >/dev/null 2>&1; then + kill -0 "$SERVER_PID" >/dev/null 2>&1 || { tail -50 "$RUN_DIR/server.log" >&2; die "health answered but scenario server exited (port collision likely)"; } + sleep 0.05 + kill -0 "$SERVER_PID" >/dev/null 2>&1 || { tail -50 "$RUN_DIR/server.log" >&2; die "scenario server exited after health check"; } + return 0 + fi + kill -0 "$SERVER_PID" >/dev/null 2>&1 || { tail -50 "$RUN_DIR/server.log" >&2; die "server exited during startup"; } + sleep 0.1 + done + die "server did not become healthy" +} + +stop_server() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + SERVER_PID="" + fi +} + +cleanup_scenario() { + local rc=$? + stop_server + if [[ -n "${RUN_DIR:-}" && -d "$RUN_DIR" ]]; then + find "$RUN_DIR" -depth -mindepth 1 -delete 2>/dev/null || true + rmdir "$RUN_DIR" 2>/dev/null || true + fi + return "$rc" +} + +phoenix_admin() { PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" "$@"; } +api_admin() { + local method="$1" path="$2" body="${3:-}" + local -a args=(-fsS -X "$method" "$PHOENIX_SERVER$path" -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json') + [[ "$PHOENIX_SERVER" != https://* ]] || args+=(--cacert "$RUN_DIR/ca.crt") + [[ -z "$body" ]] || args+=(--data "$body") + curl "${args[@]}" +} +api_token() { + local token="$1" method="$2" path="$3" body="${4:-}" + local -a args=(-fsS -X "$method" "$PHOENIX_SERVER$path" -H "Authorization: Bearer $token" -H 'Content-Type: application/json') + [[ "$PHOENIX_SERVER" != https://* ]] || args+=(--cacert "$RUN_DIR/ca.crt") + [[ -z "$body" ]] || args+=(--data "$body") + curl "${args[@]}" +} diff --git a/tests/e2e/run-all.sh b/tests/e2e/run-all.sh new file mode 100755 index 0000000..5e21615 --- /dev/null +++ b/tests/e2e/run-all.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$ROOT_DIR/lib.sh" + +usage() { + cat <<'EOF' +Usage: ./run-all.sh [--list] [--scenarios 01,05,13-foo] [--format human|jsonl|tap] + +Scenarios are auto-discovered from executable scenario-NN-name.sh files. +Exit 0 = pass, 77 = skip, any other nonzero = fail. +EOF +} + +discover() { + find "$ROOT_DIR" -maxdepth 1 -type f -name 'scenario-[0-9][0-9]-*.sh' -perm -u+x -printf '%f\n' | LC_ALL=C sort +} + +LIST=0; SELECTED=""; FORMAT=human +while [[ $# -gt 0 ]]; do + case "$1" in + --list) LIST=1; shift ;; + --scenarios) [[ $# -ge 2 ]] || die "--scenarios requires a value"; SELECTED="$2"; shift 2 ;; + --format) [[ $# -ge 2 ]] || die "--format requires a value"; FORMAT="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ "$FORMAT" == human || "$FORMAT" == jsonl || "$FORMAT" == tap ]] || die "invalid format: $FORMAT" +[[ "$FORMAT" != jsonl ]] || command -v jq >/dev/null 2>&1 || die "--format jsonl requires jq" + +mapfile -t ALL < <(discover) +[[ ${#ALL[@]} -gt 0 ]] || die "no executable scenarios discovered" + +if [[ "$LIST" -eq 1 ]]; then + for file in "${ALL[@]}"; do printf '%s\n' "${file#scenario-}" | sed 's/\.sh$//'; done + exit 0 +fi + +SELECTED_FILES=() +if [[ -z "$SELECTED" ]]; then + SELECTED_FILES=("${ALL[@]}") +else + IFS=',' read -r -a wanted <<<"$SELECTED" + for raw in "${wanted[@]}"; do + item="${raw//[[:space:]]/}" + matches=() + for file in "${ALL[@]}"; do + slug="${file#scenario-}"; slug="${slug%.sh}"; id="${slug%%-*}" + if [[ "$item" == "$id" || "$item" == "$slug" || "$item" == "$file" ]]; then matches+=("$file"); fi + done + [[ ${#matches[@]} -eq 1 ]] || die "unknown or ambiguous scenario: $item" + SELECTED_FILES+=("${matches[0]}") + done +fi + +[[ "$FORMAT" != tap ]] || printf '1..%d\n' "${#SELECTED_FILES[@]}" +failures=0; index=0 +for file in "${SELECTED_FILES[@]}"; do + index=$((index + 1)); slug="${file#scenario-}"; slug="${slug%.sh}" + started="$(date +%s)"; output="$(mktemp "${TMPDIR:-/tmp}/phoenix-e2e-output.XXXXXX")" + set +e + "$ROOT_DIR/$file" >"$output" 2>&1 + rc=$? + set -e + duration=$(( $(date +%s) - started )) + if [[ $rc -eq 0 ]]; then status=pass; elif [[ $rc -eq 77 ]]; then status=skip; else status=fail; failures=$((failures + 1)); fi + + case "$FORMAT" in + human) + cat "$output" + printf '[RESULT] %s %s (%ss)\n' "$status" "$slug" "$duration" + ;; + jsonl) + jq -cn --arg scenario "$slug" --arg status "$status" --argjson exit_code "$rc" --argjson duration_seconds "$duration" \ + '{scenario:$scenario,status:$status,exit_code:$exit_code,duration_seconds:$duration_seconds}' + [[ "$status" != fail ]] || cat "$output" >&2 + ;; + tap) + if [[ "$status" == pass ]]; then + printf 'ok %d - %s\n' "$index" "$slug" + elif [[ "$status" == skip ]]; then + printf 'ok %d - %s # SKIP\n' "$index" "$slug" + else + printf 'not ok %d - %s\n' "$index" "$slug" + sed 's/^/# /' "$output" + fi + ;; + esac + rm "$output" +done + +[[ $failures -eq 0 ]] || exit 1 diff --git a/tests/e2e/scenario-01-lifecycle.sh b/tests/e2e/scenario-01-lifecycle.sh new file mode 100755 index 0000000..c85e8e8 --- /dev/null +++ b/tests/e2e/scenario-01-lifecycle.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 01 lifecycle +init_scenario 01 +start_server +phoenix_admin set lifecycle/one -v synthetic-one >/dev/null +printf 'synthetic-two' | phoenix_admin set lifecycle/two --value-stdin >/dev/null +assert_eq "$(phoenix_admin get lifecycle/one)" synthetic-one "get" +assert_contains "$(phoenix_admin list lifecycle/)" lifecycle/two "list" +phoenix_admin export lifecycle/ -f env >"$RUN_DIR/export.env" +phoenix_admin delete lifecycle/two >/dev/null +assert_not_contains "$(phoenix_admin list lifecycle/ || true)" lifecycle/two "delete" +assert_not_contains "$(cat "$RUN_DIR/store.json")" synthetic-one "plaintext at rest" +assert_contains "$(phoenix_admin audit -n 20)" lifecycle/one "audit" +ok "Scenario 01 PASS" diff --git a/tests/e2e/scenario-02-multi-agent.sh b/tests/e2e/scenario-02-multi-agent.sh new file mode 100755 index 0000000..9449f37 --- /dev/null +++ b/tests/e2e/scenario-02-multi-agent.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 02 multi-agent-isolation +init_scenario 02 +start_server +phoenix_admin set dev/key -v synthetic-dev >/dev/null +phoenix_admin set prod/key -v synthetic-prod >/dev/null +phoenix_admin agent create dev-agent -t synthetic-dev-token --acl 'dev/*:read,write' >/dev/null +phoenix_admin agent create prod-agent -t synthetic-prod-token --acl 'prod/*:read' >/dev/null +assert_eq "$(PHOENIX_TOKEN=synthetic-dev-token "$PHOENIX" get dev/key)" synthetic-dev +assert_eq "$(PHOENIX_TOKEN=synthetic-prod-token "$PHOENIX" get prod/key)" synthetic-prod +if PHOENIX_TOKEN=synthetic-dev-token "$PHOENIX" get prod/key >"$RUN_DIR/deny" 2>&1; then die "cross-namespace read allowed"; fi +assert_contains "$(phoenix_admin audit -n 30)" denied "denial audit" +ok "Scenario 02 PASS" diff --git a/tests/e2e/scenario-03-attestation.sh b/tests/e2e/scenario-03-attestation.sh new file mode 100755 index 0000000..8456998 --- /dev/null +++ b/tests/e2e/scenario-03-attestation.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 03 attestation-policy +init_scenario 03 +start_server +for p in local/key denied/key mtls/key; do phoenix_admin set "$p" -v synthetic-attestation >/dev/null; done +stop_server +cat >"$RUN_DIR/policy.json" <<'JSON' +{"attestation":{"local/*":{"source_ip":["127.0.0.1","::1"]},"denied/*":{"source_ip":["192.0.2.9"]},"mtls/*":{"require_mtls":true}}} +JSON +config_patch "d['policy']={'path':'$RUN_DIR/policy.json'}" +start_server +assert_eq "$(phoenix_admin get local/key)" synthetic-attestation +for p in denied/key mtls/key; do if phoenix_admin get "$p" >"$RUN_DIR/deny" 2>&1; then die "$p unexpectedly allowed"; fi; done +assert_contains "$(phoenix_admin audit -n 30)" attestation "attestation audit" +ok "Scenario 03 PASS" diff --git a/tests/e2e/scenario-04-exec.sh b/tests/e2e/scenario-04-exec.sh new file mode 100755 index 0000000..ec661d5 --- /dev/null +++ b/tests/e2e/scenario-04-exec.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 04 exec-credential-stripping +init_scenario 04 +start_server +phoenix_admin set exec/key -v synthetic-exec-value >/dev/null +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" exec --env APP_SECRET=phoenix://exec/key -- env >"$RUN_DIR/env" +grep -qx 'APP_SECRET=synthetic-exec-value' "$RUN_DIR/env" || die "secret not injected" +for key in PHOENIX_TOKEN PHOENIX_CLIENT_CERT PHOENIX_CLIENT_KEY PHOENIX_SERVER; do ! grep -q "^$key=" "$RUN_DIR/env" || die "$key leaked"; done +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" exec --output-env "$RUN_DIR/out.env" --env APP_SECRET=phoenix://exec/key -- true >/dev/null +grep -qx 'APP_SECRET=synthetic-exec-value' "$RUN_DIR/out.env" || die "output-env mismatch" +[[ "$(stat -c %a "$RUN_DIR/out.env")" == 600 ]] || die "output-env mode is not 0600" +ok "Scenario 04 PASS" diff --git a/tests/e2e/scenario-05-rotation.sh b/tests/e2e/scenario-05-rotation.sh new file mode 100755 index 0000000..2e96552 --- /dev/null +++ b/tests/e2e/scenario-05-rotation.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 05 rotation-under-load +init_scenario 05 +start_server +for i in $(seq 1 50); do phoenix_admin set "rotation/ns$((i%10))/key$i" -v "synthetic-rotation-$i" >/dev/null; done +( + for _ in $(seq 1 30); do PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" get rotation/ns1/key1 >/dev/null; sleep 0.05; done +) & reader=$! +rotation="$(phoenix_admin rotate-master)" +wait "$reader" +[[ -f "$RUN_DIR/master.key.prev" ]] || die "master.key.prev missing" +assert_contains "$rotation" 'Namespaces re-wrapped: 1' "rotation count" +for i in 1 17 50; do assert_eq "$(phoenix_admin get "rotation/ns$((i%10))/key$i")" "synthetic-rotation-$i"; done +assert_contains "$(phoenix_admin audit -n 100)" rotate-master "rotation audit" +ok "Scenario 05 PASS" diff --git a/tests/e2e/scenario-06-certs.sh b/tests/e2e/scenario-06-certs.sh new file mode 100755 index 0000000..b85c345 --- /dev/null +++ b/tests/e2e/scenario-06-certs.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 06 certificate-lifecycle +require_cmd openssl +init_scenario 06 +config_patch "d['auth']['mtls']['enabled']=True" +PHOENIX_SERVER="https://${PHOENIX_SERVER#http://}"; export PHOENIX_SERVER +start_server +PHOENIX_CA_CERT="$RUN_DIR/ca.crt" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" set certs/key -v synthetic-cert >/dev/null +PHOENIX_CA_CERT="$RUN_DIR/ca.crt" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" agent create cert-agent -t synthetic-unused --acl 'certs/*:read' >/dev/null +mkdir "$RUN_DIR/client" +PHOENIX_CA_CERT="$RUN_DIR/ca.crt" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" cert issue cert-agent -o "$RUN_DIR/client" >/dev/null +cert_env=(PHOENIX_CA_CERT="$RUN_DIR/ca.crt" PHOENIX_CLIENT_CERT="$RUN_DIR/client/cert-agent.crt" PHOENIX_CLIENT_KEY="$RUN_DIR/client/cert-agent.key" PHOENIX_TOKEN=) +assert_eq "$(env "${cert_env[@]}" "$PHOENIX" get certs/key)" synthetic-cert +serial="$(openssl x509 -in "$RUN_DIR/client/cert-agent.crt" -noout -serial | cut -d= -f2)" +serial_dec="$(python3 -c 'import sys; print(int(sys.argv[1],16))' "$serial")" +curl --cacert "$RUN_DIR/ca.crt" -fsS -X POST "$PHOENIX_SERVER/v1/certs/revoke" -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' --data "{\"serial_number\":\"$serial_dec\",\"agent_name\":\"cert-agent\"}" >/dev/null +if env "${cert_env[@]}" "$PHOENIX" get certs/key >"$RUN_DIR/revoked" 2>&1; then die "revoked certificate accepted"; fi +ok "Scenario 06 PASS" diff --git a/tests/e2e/scenario-07-time-nonce.sh b/tests/e2e/scenario-07-time-nonce.sh new file mode 100755 index 0000000..97d2bb2 --- /dev/null +++ b/tests/e2e/scenario-07-time-nonce.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 07 time-window-and-nonce +init_scenario 07 +start_server +phoenix_admin set window/key -v synthetic-window >/dev/null +phoenix_admin set nonce/key -v synthetic-nonce >/dev/null +stop_server +cat >"$RUN_DIR/policy.json" <<'JSON' +{"attestation":{"window/*":{"time_window":"00:00-23:59","time_zone":"UTC"},"nonce/*":{"require_nonce":true,"nonce_max_age":"5s"}}} +JSON +config_patch "d['policy']={'path':'$RUN_DIR/policy.json'}; d['attestation']['nonce']={'enabled':True,'max_age':'5s'}" +start_server +assert_eq "$(phoenix_admin get window/key)" synthetic-window +if phoenix_admin resolve phoenix://nonce/key >"$RUN_DIR/no-nonce" 2>&1; then die "nonce policy allowed unsigned request"; fi +challenge="$(api_admin POST /v1/challenge '{}')"; nonce="$(jq -r .nonce <<<"$challenge")" +body="$(jq -cn --arg n "$nonce" '{refs:["phoenix://nonce/key"],nonce:$n}')" +first="$(api_admin POST /v1/resolve "$body")" +assert_eq "$(jq -r '.values["phoenix://nonce/key"]' <<<"$first")" synthetic-nonce +code="$(curl -sS -o "$RUN_DIR/replay" -w '%{http_code}' -X POST "$PHOENIX_SERVER/v1/resolve" -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' --data "$body")" +assert_eq "$code" 403 "nonce replay status" +ok "Scenario 07 PASS" diff --git a/tests/e2e/scenario-08-short-lived.sh b/tests/e2e/scenario-08-short-lived.sh new file mode 100755 index 0000000..4e70ac1 --- /dev/null +++ b/tests/e2e/scenario-08-short-lived.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 08 short-lived-token +init_scenario 08 +config_patch "d['attestation']['token']={'enabled':True,'ttl':'2s'}" +start_server +phoenix_admin set ephemeral/key -v synthetic-ephemeral >/dev/null +phoenix_admin agent create ephemeral-agent -t synthetic-bootstrap --acl 'ephemeral/*:read' >/dev/null +mint="$(api_admin POST /v1/token/mint '{"agent":"ephemeral-agent"}')"; tok="$(jq -r .token <<<"$mint")" +assert_eq "$(PHOENIX_TOKEN="$tok" "$PHOENIX" get ephemeral/key)" synthetic-ephemeral +sleep 3 +if PHOENIX_TOKEN="$tok" "$PHOENIX" get ephemeral/key >"$RUN_DIR/expired" 2>&1; then die "expired token accepted"; fi +mint2="$(api_admin POST /v1/token/mint '{"agent":"ephemeral-agent"}')"; tok2="$(jq -r .token <<<"$mint2")" +assert_eq "$(PHOENIX_TOKEN="$tok2" "$PHOENIX" get ephemeral/key)" synthetic-ephemeral +ok "Scenario 08 PASS" diff --git a/tests/e2e/scenario-09-postgres.sh b/tests/e2e/scenario-09-postgres.sh new file mode 100755 index 0000000..ab87962 --- /dev/null +++ b/tests/e2e/scenario-09-postgres.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 09 postgres-integration +require_cmd docker +require_cmd psql +docker compose version >/dev/null 2>&1 || skip "docker compose plugin is not installed" +init_scenario 09 +compose="$E2E_DIR/docker-compose.e2e.yml" +project="phoenix-e2e-$(basename "$RUN_DIR" | tr -cd '[:alnum:]-')" +E2E_POSTGRES_PORT="$(free_port)"; export E2E_POSTGRES_PORT +compose_cmd=(docker compose -p "$project" -f "$compose") +cleanup_postgres() { local rc=$?; "${compose_cmd[@]}" down -v >/dev/null 2>&1 || true; cleanup_scenario; return "$rc"; } +trap cleanup_postgres EXIT INT TERM +"${compose_cmd[@]}" up -d postgres >/dev/null +for _ in $(seq 1 60); do PGPASSWORD=synthetic-pg-initial psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c 'select 1' >/dev/null 2>&1 && break; sleep 0.5; done +PGPASSWORD=synthetic-pg-initial psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c 'select 1' >/dev/null +start_server +phoenix_admin set integration/pg-password -v synthetic-pg-initial >/dev/null +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" exec --env PGPASSWORD=phoenix://integration/pg-password -- psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c 'select 1' >/dev/null +PGPASSWORD=synthetic-pg-initial psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c "alter user testuser password 'synthetic-pg-rotated'" >/dev/null +phoenix_admin set integration/pg-password -v synthetic-pg-rotated >/dev/null +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" exec --env PGPASSWORD=phoenix://integration/pg-password -- psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c 'select 1' >/dev/null +if PGPASSWORD=synthetic-pg-initial psql -h 127.0.0.1 -p "$E2E_POSTGRES_PORT" -U testuser -d testdb -c 'select 1' >/dev/null 2>&1; then die "old PostgreSQL password still works"; fi +ok "Scenario 09 PASS" diff --git a/tests/e2e/scenario-10-mcp.sh b/tests/e2e/scenario-10-mcp.sh new file mode 100755 index 0000000..9af0474 --- /dev/null +++ b/tests/e2e/scenario-10-mcp.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 10 mcp-stdio +init_scenario 10 +start_server +phoenix_admin set mcp/key -v synthetic-mcp >/dev/null +cat >"$RUN_DIR/mcp.in" <<'JSON' +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"phoenix_list","arguments":{"prefix":"mcp/"}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"phoenix_get","arguments":{"path":"mcp/key"}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"phoenix_resolve","arguments":{"refs":["phoenix://mcp/key"]}}} +JSON +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" mcp-server <"$RUN_DIR/mcp.in" >"$RUN_DIR/mcp.out" +python3 - "$RUN_DIR/mcp.out" <<'PY' +import json, sys +rows=[json.loads(x) for x in open(sys.argv[1], encoding='utf-8') if x.strip()] +assert [r.get('id') for r in rows] == [1,2,3,4,5], rows +text=json.dumps(rows) +for expected in ('phoenix_get','phoenix_resolve','phoenix_list','mcp/key','synthetic-mcp'): + assert expected in text, expected +PY +ok "Scenario 10 PASS" diff --git a/tests/e2e/scenario-11-zero-plaintext.sh b/tests/e2e/scenario-11-zero-plaintext.sh new file mode 100755 index 0000000..63cedc6 --- /dev/null +++ b/tests/e2e/scenario-11-zero-plaintext.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 11 zero-plaintext-config +init_scenario 11 +start_server +phoenix_admin set zero/db-password -v synthetic-zero-db >/dev/null +phoenix_admin set zero/api-key -v synthetic-zero-api >/dev/null +cat >"$RUN_DIR/compose.yml" <<'YAML' +services: + app: + image: example.invalid/synthetic-only + environment: + DATABASE_PASSWORD: phoenix://zero/db-password + API_KEY: phoenix://zero/api-key +YAML +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" verify "$RUN_DIR/compose.yml" >/dev/null +PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" exec --output-env "$RUN_DIR/resolved.env" --env DATABASE_PASSWORD=phoenix://zero/db-password --env API_KEY=phoenix://zero/api-key -- true >/dev/null +grep -qx 'DATABASE_PASSWORD=synthetic-zero-db' "$RUN_DIR/resolved.env" || die "DB env mismatch" +grep -qx 'API_KEY=synthetic-zero-api' "$RUN_DIR/resolved.env" || die "API env mismatch" +assert_not_contains "$(cat "$RUN_DIR/compose.yml" "$RUN_DIR/store.json" "$RUN_DIR/audit.log")" synthetic-zero-db "plaintext leaked outside resolved env" +assert_not_contains "$(cat "$RUN_DIR/compose.yml" "$RUN_DIR/store.json" "$RUN_DIR/audit.log")" synthetic-zero-api "plaintext leaked outside resolved env" +ok "Scenario 11 PASS" diff --git a/tests/e2e/scenario-12-1password.sh b/tests/e2e/scenario-12-1password.sh new file mode 100755 index 0000000..1650383 --- /dev/null +++ b/tests/e2e/scenario-12-1password.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 12 onepassword-bridge +skip "external 1Password credentials are intentionally excluded; run only in a separately authorized synthetic vault" diff --git a/tests/e2e/scenario-13-openclaw-exec-provider.sh b/tests/e2e/scenario-13-openclaw-exec-provider.sh new file mode 100755 index 0000000..bb3087a --- /dev/null +++ b/tests/e2e/scenario-13-openclaw-exec-provider.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 13 openclaw-exec-provider +init_scenario 13 +start_server +phoenix_admin set openclaw/one -v synthetic-openclaw-one >/dev/null +phoenix_admin set openclaw/two -v synthetic-openclaw-two >/dev/null +run_provider() { PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" openclaw-exec-provider; } +out="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":["openclaw/one","phoenix://openclaw/two"]}' | run_provider)" +assert_eq "$(jq -r '.values["openclaw/one"]' <<<"$out")" synthetic-openclaw-one +assert_eq "$(jq -r '.values["phoenix://openclaw/two"]' <<<"$out")" synthetic-openclaw-two +partial="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":["openclaw/one","openclaw/missing"]}' | run_provider)" +assert_eq "$(jq -r '.values["openclaw/one"]' <<<"$partial")" synthetic-openclaw-one +[[ "$(jq -r '.errors["openclaw/missing"].message' <<<"$partial")" != null ]] || die "per-id error missing" +empty="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":[]}' | PHOENIX_SERVER=http://127.0.0.1:1 PHOENIX_TOKEN=synthetic-unused "$PHOENIX" openclaw-exec-provider)" +assert_eq "$(jq -r '.values | length' <<<"$empty")" 0 +if printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":[]}{}' | run_provider >"$RUN_DIR/trailing.out" 2>"$RUN_DIR/trailing.err"; then die "trailing JSON accepted"; fi +assert_contains "$(cat "$RUN_DIR/trailing.err")" 'trailing data' +dupe="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":["openclaw/one","openclaw/one"]}' | run_provider)" +assert_eq "$(jq -r '.values["openclaw/one"]' <<<"$dupe")" synthetic-openclaw-one +alias="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":["openclaw/two"]}' | PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" resolve --stdin-json)" +assert_eq "$(jq -r '.values["openclaw/two"]' <<<"$alias")" synthetic-openclaw-two +ok "Scenario 13 PASS" diff --git a/tests/e2e/scenario-14-auth-modes.sh b/tests/e2e/scenario-14-auth-modes.sh new file mode 100755 index 0000000..98e1ca1 --- /dev/null +++ b/tests/e2e/scenario-14-auth-modes.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 14 openclaw-auth-modes +init_scenario 14 +config_patch "d['auth']['mtls']['enabled']=True; d['session']={'enabled':True,'ttl':'3s','roles':{'openclaw':{'namespaces':['auth/*'],'actions':['read_value'],'bootstrap_trust':['bearer','mtls']},'approval':{'namespaces':['auth/*'],'actions':['read_value'],'bootstrap_trust':['bearer'],'step_up':True,'step_up_ttl':'1s'}}}" +PHOENIX_SERVER="https://${PHOENIX_SERVER#http://}"; export PHOENIX_SERVER +start_server +base=(PHOENIX_SERVER="$PHOENIX_SERVER" PHOENIX_CA_CERT="$RUN_DIR/ca.crt") +env "${base[@]}" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" set auth/key -v synthetic-auth >/dev/null +env "${base[@]}" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" agent create openclaw-agent -t synthetic-openclaw-bootstrap --acl 'auth/*:read' >/dev/null +mkdir "$RUN_DIR/client" +env "${base[@]}" PHOENIX_TOKEN="$ADMIN_TOKEN" "$PHOENIX" cert issue openclaw-agent -o "$RUN_DIR/client" >/dev/null +request='{"protocolVersion":1,"provider":"phoenix","ids":["auth/key"]}' +provider_value() { jq -r '.values["auth/key"]'; } +bearer="$(printf '%s' "$request" | env "${base[@]}" HOME="$RUN_DIR/home-bearer" PHOENIX_TOKEN=synthetic-openclaw-bootstrap PHOENIX_ROLE=openclaw "$PHOENIX" openclaw-exec-provider | provider_value)" +assert_eq "$bearer" synthetic-auth "bearer role mode" +mtls="$(printf '%s' "$request" | env "${base[@]}" HOME="$RUN_DIR/home-mtls" PHOENIX_TOKEN= PHOENIX_ROLE=openclaw PHOENIX_CLIENT_CERT="$RUN_DIR/client/openclaw-agent.crt" PHOENIX_CLIENT_KEY="$RUN_DIR/client/openclaw-agent.key" "$PHOENIX" openclaw-exec-provider | provider_value)" +assert_eq "$mtls" synthetic-auth "mTLS role mode" +mint="$(curl --cacert "$RUN_DIR/ca.crt" -fsS -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-openclaw-bootstrap' -H 'Content-Type: application/json' --data '{"role":"openclaw"}')"; session="$(jq -r .session_token <<<"$mint")" +preminted="$(printf '%s' "$request" | env "${base[@]}" PHOENIX_TOKEN="$session" PHOENIX_ROLE= "$PHOENIX" openclaw-exec-provider | provider_value)" +assert_eq "$preminted" synthetic-auth "pre-minted mode" +homeless="$(printf '%s' "$request" | env -u HOME "${base[@]}" PHOENIX_TOKEN=synthetic-openclaw-bootstrap PHOENIX_ROLE=openclaw "$PHOENIX" openclaw-exec-provider | provider_value)" +assert_eq "$homeless" synthetic-auth "HOME-unset bootstrap" +fresh="$(curl --cacert "$RUN_DIR/ca.crt" -fsS -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-openclaw-bootstrap' -H 'Content-Type: application/json' --data '{"role":"openclaw"}' | jq -r .session_token)" +if printf '%s' "$request" | env "${base[@]}" HOME="$RUN_DIR/home-invalid" PHOENIX_TOKEN="$fresh" PHOENIX_ROLE=openclaw "$PHOENIX" openclaw-exec-provider >"$RUN_DIR/invalid" 2>&1; then die "PHOENIX_ROLE plus phxs token accepted"; fi +sleep 4 +if printf '%s' "$request" | env "${base[@]}" PHOENIX_TOKEN="$fresh" PHOENIX_ROLE= "$PHOENIX" openclaw-exec-provider >"$RUN_DIR/expired" 2>&1; then die "expired pre-minted session accepted"; fi +approval_code="$(curl --cacert "$RUN_DIR/ca.crt" -sS -o "$RUN_DIR/approval" -w '%{http_code}' -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-openclaw-bootstrap' -H 'Content-Type: application/json' --data '{"role":"approval"}')" +assert_eq "$approval_code" 202 "step-up role response" +assert_eq "$(jq -r .code "$RUN_DIR/approval")" APPROVAL_REQUIRED +ok "Scenario 14 PASS" diff --git a/tests/e2e/scenario-15-audit-metadata.sh b/tests/e2e/scenario-15-audit-metadata.sh new file mode 100755 index 0000000..0616eb5 --- /dev/null +++ b/tests/e2e/scenario-15-audit-metadata.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 15 openclaw-audit-metadata +init_scenario 15 +start_server +phoenix_admin set auditmeta/allowed -v synthetic-audit >/dev/null +phoenix_admin set restricted/key -v synthetic-restricted >/dev/null +phoenix_admin agent create audit-reader -t synthetic-audit-reader --acl 'auditmeta/*:read' >/dev/null +long="$(python3 -c 'print("x"*300)')"; formatted=$'agent\u202Ename'; sentinel=synthetic-session-key-must-not-log +curl -fsS "$PHOENIX_SERVER/v1/secrets/auditmeta/allowed" -H 'Authorization: Bearer synthetic-audit-reader' -H "X-OpenClaw-Agent: $formatted" -H 'X-OpenClaw-Session-Id: session-123' -H "X-OpenClaw-Channel: $long" -H 'X-OpenClaw-Requester-Sender: synthetic@example.invalid' -H 'X-OpenClaw-Sender-Is-Owner: true' -H "X-OpenClaw-Session-Key: $sentinel" >/dev/null +code="$(curl -sS -o "$RUN_DIR/spoof" -w '%{http_code}' "$PHOENIX_SERVER/v1/secrets/restricted/key" -H 'Authorization: Bearer synthetic-audit-reader' -H 'X-OpenClaw-Agent: admin' -H 'X-OpenClaw-Sender-Is-Owner: true')" +assert_eq "$code" 403 "spoofed authorization" +audit="$(api_admin GET /v1/audit)" +assert_not_contains "$audit" "$sentinel" "session key leaked" +entry="$(jq -c '[.entries[] | select(.path=="auditmeta/allowed" and .action=="read_value")][-1]' <<<"$audit")" +assert_eq "$(jq -r .agent <<<"$entry")" audit-reader +assert_eq "$(jq -r '.metadata["openclaw.agent"]' <<<"$entry")" agentname +assert_eq "$(jq -r '.metadata["openclaw.channel"] | length' <<<"$entry")" 256 +[[ "$(jq -r '.metadata | has("openclaw.session_key")' <<<"$entry")" == false ]] || die "session key metadata captured" +deny="$(jq -c '[.entries[] | select(.path=="restricted/key" and .status=="denied")][-1]' <<<"$audit")" +assert_eq "$(jq -r .agent <<<"$deny")" audit-reader +assert_eq "$(jq -r '.metadata["openclaw.agent"]' <<<"$deny")" admin +ok "Scenario 15 PASS" diff --git a/tests/e2e/scenario-16-stepup.sh b/tests/e2e/scenario-16-stepup.sh new file mode 100755 index 0000000..0a226e4 --- /dev/null +++ b/tests/e2e/scenario-16-stepup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 16 elevated-step-up +init_scenario 16 +config_patch "d['session']={'enabled':True,'ttl':'3s','roles':{'prod-stepup':{'namespaces':['prod/*'],'actions':['read_value'],'bootstrap_trust':['bearer'],'step_up':True,'step_up_ttl':'5s','elevates_acl':True}}}" +start_server +phoenix_admin set prod/key -v synthetic-prod-stepup >/dev/null +phoenix_admin agent create stepup-reader -t synthetic-stepup-reader --acl 'dev/*:read' >/dev/null +mint() { curl -fsS -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-stepup-reader' -H 'Content-Type: application/json' --data '{"role":"prod-stepup"}'; } +first="$(mint)"; deny_id="$(jq -r .approval_id <<<"$first")" +api_admin POST "/v1/approval/$deny_id/deny" '{}' >/dev/null +status="$(api_token synthetic-stepup-reader GET "/v1/approval/$deny_id")" +assert_eq "$(jq -r .status <<<"$status")" denied +second="$(mint)"; approve_id="$(jq -r .approval_id <<<"$second")" +api_admin POST "/v1/approval/$approve_id/approve" '{}' >/dev/null +approved="$(api_token synthetic-stepup-reader GET "/v1/approval/$approve_id")"; session="$(jq -r .session_token <<<"$approved")" +[[ "$session" == phxs_* ]] || die "approved session token missing" +assert_eq "$(PHOENIX_TOKEN="$session" "$PHOENIX" get prod/key)" synthetic-prod-stepup +renew_code="$(curl -sS -o "$RUN_DIR/renew" -w '%{http_code}' -X POST "$PHOENIX_SERVER/v1/session/renew" -H "Authorization: Bearer $session" -H 'Content-Type: application/json' --data '{}')" +assert_eq "$renew_code" 403 "elevated renewal status" +assert_eq "$(jq -r .code "$RUN_DIR/renew")" STEP_UP_REAPPROVAL_REQUIRED +sleep 4 +if PHOENIX_TOKEN="$session" "$PHOENIX" get prod/key >"$RUN_DIR/expired" 2>&1; then die "expired step-up session accepted"; fi +ok "Scenario 16 PASS" diff --git a/tests/e2e/scenario-17-sealed.sh b/tests/e2e/scenario-17-sealed.sh new file mode 100755 index 0000000..cdf3f06 --- /dev/null +++ b/tests/e2e/scenario-17-sealed.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 17 sealed-responses-s1-s8 +init_scenario 17 +start_server +phoenix_admin set open/key -v synthetic-open >/dev/null +phoenix_admin set sealed/key -v synthetic-sealed >/dev/null +assert_eq "$(phoenix_admin get open/key)" synthetic-open "S1 plaintext-compatible open path" +stop_server +cat >"$RUN_DIR/policy.json" <<'JSON' +{"attestation":{"sealed/*":{"require_sealed":true,"allow_unseal":false},"open/*":{"allow_unseal":true}}} +JSON +config_patch "d['policy']={'path':'$RUN_DIR/policy.json'}" +start_server +keyfile="$RUN_DIR/seal.key" +phoenix_admin agent create sealed-agent -t synthetic-sealed-agent --acl 'sealed/*:read;open/*:read' >/dev/null +keyout="$(phoenix_admin keypair generate sealed-agent -o "$keyfile")" +[[ "$(stat -c %a "$keyfile")" == 600 ]] || die "S2 seal key mode is not 0600" +public="$(awk '/public key:/ {print $3}' <<<"$keyout")"; [[ -n "$public" ]] || die "S2 public key missing" +assert_eq "$(PHOENIX_TOKEN=synthetic-sealed-agent PHOENIX_SEAL_KEY="$keyfile" "$PHOENIX" get open/key)" synthetic-open "S3 sealed open read" +if PHOENIX_TOKEN=synthetic-sealed-agent "$PHOENIX" get sealed/key >"$RUN_DIR/unsealed" 2>&1; then die "S4 require_sealed allowed plaintext"; fi +assert_eq "$(PHOENIX_TOKEN=synthetic-sealed-agent PHOENIX_SEAL_KEY="$keyfile" "$PHOENIX" get sealed/key)" synthetic-sealed "S5 sealed required read" +wire="$(curl -fsS -X POST "$PHOENIX_SERVER/v1/resolve" -H 'Authorization: Bearer synthetic-sealed-agent' -H 'Content-Type: application/json' -H "X-Phoenix-Seal-Key: $public" --data '{"refs":["phoenix://sealed/key"]}')" +[[ "$(jq -r 'has("sealed_values") and (has("values")|not)' <<<"$wire")" == true ]] || die "S6 wire response was not sealed-only" +code="$(curl -sS -o "$RUN_DIR/badseal" -w '%{http_code}' -X POST "$PHOENIX_SERVER/v1/resolve" -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -H 'X-Phoenix-Seal-Key: invalid' --data '{"refs":["phoenix://sealed/key"]}')" +assert_eq "$code" 400 "S7 invalid seal key" +show="$(PHOENIX_TOKEN="$ADMIN_TOKEN" PHOENIX_POLICY="$RUN_DIR/policy.json" "$PHOENIX" policy show sealed/key)" +assert_contains "$show" 'require_sealed: true' "S8 policy show regression" +if phoenix_admin keypair generate bad-output -o "$RUN_DIR" >"$RUN_DIR/keypair-dir" 2>&1; then die "S8 keypair directory output accepted"; fi +assert_contains "$(cat "$RUN_DIR/keypair-dir")" 'output must be a file path' "S8 keypair regression message" +ok "Scenario 17 PASS (S1-S8)" diff --git a/tests/e2e/scenario-18-upgrade.sh b/tests/e2e/scenario-18-upgrade.sh new file mode 100755 index 0000000..4abea93 --- /dev/null +++ b/tests/e2e/scenario-18-upgrade.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 18 upgrade-0.13.5-to-0.15.2 +require_cmd git +require_cmd tar +require_cmd curl +require_cmd python3 +require_cmd jq +build_current +NEW_PHOENIX="$PHOENIX"; NEW_SERVER="$PHOENIX_SERVER_BIN" +RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/phoenix-e2e-s18.XXXXXX")"; SERVER_PID=""; trap cleanup_scenario EXIT INT TERM +mkdir "$RUN_DIR/old-src" "$RUN_DIR/old-bin" "$RUN_DIR/state" +git -C "$REPO_ROOT" archive 21a6ca4 | tar -x -C "$RUN_DIR/old-src" +(cd "$RUN_DIR/old-src" && "$GO_BIN" build -o "$RUN_DIR/old-bin/" ./cmd/...) +PHOENIX="$RUN_DIR/old-bin/phoenix"; PHOENIX_SERVER_BIN="$RUN_DIR/old-bin/phoenix-server" +"$PHOENIX_SERVER_BIN" --init "$RUN_DIR/state" >"$RUN_DIR/init.out" +ADMIN_TOKEN="$(python3 - "$RUN_DIR/init.out" <<'PY' +import re,sys +s=open(sys.argv[1]).read(); m=re.search(r'ADMIN TOKEN.*?\n([a-f0-9]{32,})',s,re.S); print(m.group(1) if m else '') +PY +)"; [[ -n "$ADMIN_TOKEN" ]] || die "old init token missing" +port="$(free_port)" +python3 - "$RUN_DIR/state/config.json" "$port" <<'PY' +import json,sys +p=sys.argv[1]; d=json.load(open(p)); d['server']['listen']='127.0.0.1:'+sys.argv[2]; d['session']={'enabled':True,'ttl':'10m','roles':{'upgrade-reader':{'namespaces':['upgrade/*'],'actions':['read_value'],'bootstrap_trust':['bearer']}}}; json.dump(d,open(p,'w'),indent=2) +PY +PHOENIX_SERVER="http://127.0.0.1:$port"; export PHOENIX_SERVER +# Helpers expect config at RUN_DIR/config.json; point through a temporary symlink. +ln -s "$RUN_DIR/state/config.json" "$RUN_DIR/config.json" +start_server +phoenix_admin set upgrade/key -v synthetic-upgrade >/dev/null +phoenix_admin agent create upgrade-agent -t synthetic-upgrade-agent --acl 'upgrade/*:read' >/dev/null +old_mint="$(curl -fsS -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-upgrade-agent' -H 'Content-Type: application/json' --data '{"role":"upgrade-reader"}')"; old_session="$(jq -r .session_token <<<"$old_mint")" +assert_eq "$(PHOENIX_TOKEN="$old_session" "$PHOENIX" get upgrade/key)" synthetic-upgrade +old_audit_count="$(wc -l <"$RUN_DIR/state/audit.log")" +stop_server +PHOENIX="$NEW_PHOENIX"; PHOENIX_SERVER_BIN="$NEW_SERVER" +start_server +assert_eq "$(phoenix_admin get upgrade/key)" synthetic-upgrade "store compatibility" +assert_eq "$(PHOENIX_TOKEN=synthetic-upgrade-agent "$PHOENIX" get upgrade/key)" synthetic-upgrade "ACL compatibility" +if PHOENIX_TOKEN="$old_session" "$PHOENIX" get upgrade/key >"$RUN_DIR/old-session" 2>&1; then die "pre-restart in-memory session unexpectedly survived"; fi +new_mint="$(curl -fsS -X POST "$PHOENIX_SERVER/v1/session/mint" -H 'Authorization: Bearer synthetic-upgrade-agent' -H 'Content-Type: application/json' --data '{"role":"upgrade-reader"}')"; new_session="$(jq -r .session_token <<<"$new_mint")" +assert_eq "$(PHOENIX_TOKEN="$new_session" "$PHOENIX" get upgrade/key)" synthetic-upgrade "session config compatibility" +[[ "$(wc -l <"$RUN_DIR/state/audit.log")" -gt "$old_audit_count" ]] || die "audit log was not preserved/appended" +provider="$(printf '%s' '{"protocolVersion":1,"provider":"phoenix","ids":["upgrade/key"]}' | PHOENIX_TOKEN="$new_session" "$PHOENIX" openclaw-exec-provider)" +assert_eq "$(jq -r '.values["upgrade/key"]' <<<"$provider")" synthetic-upgrade "new feature after upgrade" +ok "Scenario 18 PASS" diff --git a/tests/e2e/stack.sh b/tests/e2e/stack.sh new file mode 100755 index 0000000..81d6cd2 --- /dev/null +++ b/tests/e2e/stack.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose.e2e.yml" +COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-phoenix-e2e-$(id -u)}" +compose=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE") +command -v docker >/dev/null 2>&1 || { echo 'docker is required' >&2; exit 1; } +docker compose version >/dev/null 2>&1 || { echo 'docker compose plugin is required' >&2; exit 1; } +case "${1:-}" in + up) "${compose[@]}" up -d --build; "${compose[@]}" ps ;; + down) "${compose[@]}" down -v ;; + ps) "${compose[@]}" ps ;; + logs) "${compose[@]}" logs --tail 100 "${2:-}" ;; + *) echo "Usage: $0 {up|down|ps|logs [service]}" >&2; exit 2 ;; +esac From 0196348e2645dd7be2e3e290897a3002d2bf1871 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 19 Jul 2026 13:54:28 -0400 Subject: [PATCH 08/11] test(e2e): implement 1Password backend scenario --- tests/e2e/README.md | 4 +-- tests/e2e/scenario-12-1password.sh | 52 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 77161f4..97ae07a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -10,7 +10,7 @@ The canonical Phoenix e2e suite. It runs only throwaway state, localhost-bound s ./tests/e2e/run-all.sh --format jsonl # or: --format tap ``` -The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PATH Go executable, `E2E_BIN_DIR` to reuse prebuilt binaries, or `E2E_REBUILD=1` to force a rebuild. Scenario 09 additionally needs Docker Compose and `psql`; it chooses a per-run project and free localhost port. Manual `stack.sh` use can override `COMPOSE_PROJECT_NAME` and the `E2E_*_PORT` variables. Scenario 18 builds Git commit `21a6ca4` (v0.13.5) and the current checkout. +The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PATH Go executable, `E2E_BIN_DIR` to reuse prebuilt binaries, or `E2E_REBUILD=1` to force a rebuild. Scenario 09 additionally needs Docker Compose and `psql`; it chooses a per-run project and free localhost port. Scenario 12 is opt-in: it needs the `op` CLI, `OP_SERVICE_ACCOUNT_TOKEN`, and a read-only synthetic vault selected by `PHOENIX_E2E_OP_VAULT` (default `phoenix-test`). Manual `stack.sh` use can override `COMPOSE_PROJECT_NAME` and the `E2E_*_PORT` variables. Scenario 18 builds Git commit `21a6ca4` (v0.13.5) and the current checkout. ## Add a scenario @@ -37,7 +37,7 @@ The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PA | 01–04 | lifecycle, ACL isolation, attestation, exec stripping | implemented | | 05–08 | rotation under load, cert lifecycle, time/nonce, short-lived tokens | implemented | | 09–11 | PostgreSQL rotation, MCP stdio, zero-plaintext config | implemented | -| 12 | 1Password bridge | deferred: requires a separately authorized synthetic vault; exits 77 | +| 12 | 1Password read-only backend: resolve, write rejection, missing item | implemented; opt-in, exits 77 when `OP_SERVICE_ACCOUNT_TOKEN` is unset | | 13 | OpenClaw exec-provider protocol and edge cases | implemented | | 14 | bearer-role, mTLS-role, pre-minted session, invalid auth combinations | implemented | | 15 | sanitized audit metadata and spoof resistance | implemented | diff --git a/tests/e2e/scenario-12-1password.sh b/tests/e2e/scenario-12-1password.sh index 1650383..e00d282 100755 --- a/tests/e2e/scenario-12-1password.sh +++ b/tests/e2e/scenario-12-1password.sh @@ -2,4 +2,54 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" scenario_start 12 onepassword-bridge -skip "external 1Password credentials are intentionally excluded; run only in a separately authorized synthetic vault" + +[[ -n "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]] || skip "OP_SERVICE_ACCOUNT_TOKEN is unset; 1Password backend test is opt-in" +require_cmd op + +OP_VAULT="${PHOENIX_E2E_OP_VAULT:-phoenix-test}" +items="$(op item list --vault "$OP_VAULT" --format=json)" || die "could not list the configured 1Password test vault" + +fixture="" +while IFS= read -r item_id; do + fixture="$(op item get "$item_id" --vault "$OP_VAULT" --format=json | jq -r ' + .title as $title + | .fields[] + | select(.type == "CONCEALED" or .type == "STRING" or .type == "PASSWORD" or .type == "EMAIL" or .type == "URL") + | select(($title | test("^[a-z0-9_-]+$")) and (.label | test("^[a-z0-9_-]+$"))) + | [$title, .label] + | @tsv + ' | head -n 1)" + [[ -z "$fixture" ]] || break +done < <(jq -r '.[].id' <<<"$items") +[[ -n "$fixture" ]] || die "configured 1Password test vault has no usable synthetic item field" + +IFS=$'\t' read -r item_name field_name <<<"$fixture" +secret_path="$item_name/$field_name" +expected="$(op read "op://$OP_VAULT/$item_name/$field_name")" +[[ -n "$expected" ]] || die "synthetic 1Password fixture resolved to an empty value" + +init_scenario 12 +vault_json="$(jq -Rn --arg value "$OP_VAULT" '$value')" +config_patch "d['store']['backend']='1password'; d['onepassword']={'enabled': True, 'vault': $vault_json, 'service_account_token_env': 'OP_SERVICE_ACCOUNT_TOKEN', 'cache_ttl': '0s'}" +start_server + +actual="$(phoenix_admin resolve "phoenix://$secret_path")" +assert_eq "$actual" "$expected" "Phoenix reference did not resolve the 1Password value" +unset actual expected +ok "Phoenix reference resolved through the 1Password backend" + +write_code="$(curl -sS -o "$RUN_DIR/write.json" -w '%{http_code}' -X PUT \ + "$PHOENIX_SERVER/v1/secrets/e2e/write-probe" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + --data '{"value":"synthetic-write-probe"}')" +assert_eq "$write_code" "405" "1Password backend accepted a write" +assert_contains "$(jq -r '.error // ""' "$RUN_DIR/write.json")" "read-only" "write rejection was not explicit" +ok "read-only backend rejected writes" + +missing_ref="phoenix://e2e-missing-item-$$/password" +if missing_output="$(phoenix_admin resolve "$missing_ref" 2>&1)"; then + die "missing 1Password item unexpectedly resolved" +fi +assert_contains "$missing_output" "secret not found" "missing item did not return a clean not-found error" +ok "missing 1Password item failed cleanly" From 34303533a4527616b626b8feb9aa759f8d587092 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 19 Jul 2026 19:49:05 -0400 Subject: [PATCH 09/11] fix(config): default example listener to loopback, document non-loopback TLS requirement Co-Authored-By: Claude Fable 5 --- docs/configuration.md | 3 +++ internal/config/config.go | 4 +++- internal/config/config_test.go | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index f3f19f9..5fdca2b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,6 +3,9 @@ ## Configuration reference The server reads a JSON config file. `config.example.json` is a starter template. +The example binds to loopback (`127.0.0.1`): binding to a non-loopback address +requires enabling TLS/mTLS — never expose the plaintext HTTP listener beyond +localhost. | Field | Description | Default | |-------|-------------|---------| diff --git a/internal/config/config.go b/internal/config/config.go index 48cdaba..269ccb3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,7 +196,9 @@ func DefaultConfig() *Config { // area without having to cross-reference multiple docs. func ExampleConfig() *Config { cfg := DefaultConfig() - cfg.Server.Listen = "0.0.0.0:9090" + // Loopback by default: binding to a non-loopback address requires enabling + // TLS/mTLS — never expose the plaintext HTTP listener beyond localhost. + cfg.Server.Listen = "127.0.0.1:9090" cfg.Store.Backend = "file" cfg.Attestation.Nonce.Enabled = false cfg.Attestation.Nonce.MaxAge = "30s" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 08ee534..45eeab8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -463,6 +463,9 @@ func TestDashboardConfigJSON(t *testing.T) { func TestExampleConfigIncludesSessionAndDashboard(t *testing.T) { cfg := ExampleConfig() + if cfg.Server.Listen != "127.0.0.1:9090" { + t.Fatalf("server.listen = %q, want loopback 127.0.0.1:9090", cfg.Server.Listen) + } if cfg.Session.TTL != "1h" { t.Fatalf("session.ttl = %q, want 1h", cfg.Session.TTL) } From b52242bf7957da3c091d22f882ffc38cc943b212 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 19 Jul 2026 20:11:23 -0400 Subject: [PATCH 10/11] docs(acl): document single-level /* vs recursive /** path glob semantics Co-Authored-By: Claude Fable 5 --- cmd/phoenix/main.go | 1 + docs/admin-token-lifecycle.md | 8 +++++--- docs/authentication.md | 24 +++++++++++++++++++++-- docs/cli-usage.md | 7 +++++-- docs/configuration.md | 4 ++-- docs/lan-deployment.md | 12 ++++++------ docs/migration-env-to-phoenix.md | 10 +++++++--- docs/multi-agent-setup.md | 14 ++++++------- docs/policy-and-attestation.md | 12 ++++++------ docs/reference-only-enforcement-design.md | 4 ++-- docs/sealed-responses.md | 6 +++--- docs/session-identity.md | 4 ++-- 12 files changed, 68 insertions(+), 38 deletions(-) diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index aab7f73..eb3adda 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -338,6 +338,7 @@ Usage: phoenix import --from 1password --vault --prefix

[--item ] [--dry-run] [--skip-existing] phoenix audit [-n N] [-a agent] [-s time] Query audit log phoenix agent create -t --acl [--force] + ACL path globs: ns/* = one level, ns/** = recursive phoenix agent list List agents phoenix agent delete Delete an agent phoenix resolve [--signed] [ref...] Resolve phoenix:// references to values diff --git a/docs/admin-token-lifecycle.md b/docs/admin-token-lifecycle.md index cc0a5f2..ae52830 100644 --- a/docs/admin-token-lifecycle.md +++ b/docs/admin-token-lifecycle.md @@ -39,8 +39,8 @@ Example bootstrap flow (local server, default config): export PHOENIX_SERVER="http://127.0.0.1:9090" export PHOENIX_TOKEN="" -phoenix agent create app-runtime -t "runtime-token" --acl "myapp/*:read" -phoenix agent create app-deployer -t "deploy-token" --acl "myapp/*:read,write" +phoenix agent create app-runtime -t "runtime-token" --acl "myapp/**:read" +phoenix agent create app-deployer -t "deploy-token" --acl "myapp/**:read,write" ``` If mTLS is enabled (required for `phoenix cert issue`): @@ -63,7 +63,9 @@ unset PHOENIX_TOKEN - App runtime: read-only runtime identity - Human operator workflows: use named admin identities with mTLS where possible -Keep ACL scopes narrow (`namespace/*`) and action-specific (`read`, `write`, `delete`, `admin`). +Keep ACL scopes narrow (`namespace/**`, or `namespace/*` for a single level — +see [ACL path patterns](authentication.md#acl-path-patterns)) and +action-specific (`read`, `write`, `delete`, `admin`). ## 4) Short-lived tokens (optional hardening) diff --git a/docs/authentication.md b/docs/authentication.md index 2a3c52e..0374813 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,10 +11,30 @@ Create an agent with scoped permissions: ```bash phoenix agent create deployer \ -t "deploy-token-abc" \ - --acl "myapp/*:read;staging/*:read,write" + --acl "myapp/**:read;staging/**:read,write" ``` -The `deployer` agent can read anything under `myapp/` and read/write under `staging/`. +The `deployer` agent can read anything under `myapp/` and read/write under `staging/`, +including nested paths. + +### ACL path patterns + +ACL paths are glob patterns matched against secret paths: + +| Pattern | Matches | +|---------|---------| +| `myapp/db-password` | exactly that path | +| `myapp/*` | one level under `myapp/` (`myapp/db-password`, but **not** `myapp/gateway/auth-token`) | +| `myapp/**` | everything under `myapp/`, at any depth | +| `*` | every path | + +`/*` is single-level by design — it grants the top of a namespace without +exposing nested sub-trees. If an agent with `ns/*:read` is denied on +`ns/sub/key`, that is why: use `ns/**:read` for namespace-wide access. + +The same pattern semantics apply to policy path patterns +([Policy and Attestation](policy-and-attestation.md)) and session role +namespaces ([Session Identity](session-identity.md)). ## Mutual TLS (recommended) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 7654abf..e51d1a5 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -137,12 +137,12 @@ See [Session Identity](session-identity.md) for full details. # Create an agent with scoped ACL (admin only) phoenix agent create deployer \ -t "$(openssl rand -hex 32)" \ - --acl "myapp/*:read;staging/*:read,write" + --acl "myapp/**:read;staging/**:read,write" # Update an existing agent (re-create with --force) phoenix agent create deployer \ -t "$(openssl rand -hex 32)" \ - --acl "myapp/*:read,write" --force + --acl "myapp/**:read,write" --force # List all agents (admin only) phoenix agent list @@ -151,6 +151,9 @@ phoenix agent list phoenix agent delete deployer ``` +ACL paths are glob patterns: `ns/*` matches one level, `ns/**` matches +recursively. See [ACL path patterns](authentication.md#acl-path-patterns). + ## Certificate management Requires `auth.mtls.enabled: true` in the server config. diff --git a/docs/configuration.md b/docs/configuration.md index 5fdca2b..c3bec9d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,12 +44,12 @@ See [Session Identity](session-identity.md) for the full guide. "ttl": "1h", "roles": { "dev": { - "namespaces": ["dev/*", "staging/*"], + "namespaces": ["dev/**", "staging/**"], "actions": ["list", "read_value"], "bootstrap_trust": ["bearer"] }, "deploy": { - "namespaces": ["prod/*"], + "namespaces": ["prod/**"], "actions": ["list", "read_value"], "bootstrap_trust": ["mtls"], "require_seal_key": true, diff --git a/docs/lan-deployment.md b/docs/lan-deployment.md index c17eb0e..dd9b18d 100644 --- a/docs/lan-deployment.md +++ b/docs/lan-deployment.md @@ -105,11 +105,11 @@ export PHOENIX_CA_CERT="/data/phoenix/ca.crt" # Create agents with least-privilege ACLs phoenix agent create host-a-builder \ -t "$(openssl rand -hex 32)" \ - --acl "build/*:read;ci/*:read" + --acl "build/**:read;ci/**:read" phoenix agent create host-b-deployer \ -t "$(openssl rand -hex 32)" \ - --acl "deploy/*:read;infra/*:read" + --acl "deploy/**:read;infra/**:read" ``` ## 3. Issue mTLS certificates @@ -208,14 +208,14 @@ require mTLS, require sealed delivery for sensitive namespaces: ```json { "attestation": { - "dev/*": { + "dev/**": { "require_mtls": false }, - "build/*": { + "build/**": { "require_mtls": true, "source_ip": ["192.168.1.0/24"] }, - "production/*": { + "production/**": { "require_mtls": true, "require_sealed": true, "deny_bearer": true, @@ -277,7 +277,7 @@ phoenix set build/test-key -v "test-value" -d "deployment test" # Resolve from the appropriate agent phoenix resolve phoenix://build/test-key -# Verify access control — this should fail for agents without build/* access +# Verify access control — this should fail for agents without build/** access phoenix get build/test-key # Check audit trail (from admin) diff --git a/docs/migration-env-to-phoenix.md b/docs/migration-env-to-phoenix.md index e40e4ed..b821c02 100644 --- a/docs/migration-env-to-phoenix.md +++ b/docs/migration-env-to-phoenix.md @@ -11,7 +11,7 @@ Identify secrets currently in: - Docker Compose env blocks - framework config files -Group by namespace you want in Phoenix (for example `myapp/*`, `staging/*`, `production/*`). +Group by namespace you want in Phoenix (for example `myapp/`, `staging/`, `production/`). ## 2) Initialize Phoenix @@ -39,10 +39,14 @@ See [CLI Usage](cli-usage.md) for import options including 1Password migration ## 4) Create least-privilege agent identities ```bash -phoenix agent create myapp-runtime -t "runtime-token" --acl "myapp/*:read" -phoenix agent create myapp-deployer -t "deploy-token" --acl "myapp/*:read,write" +phoenix agent create myapp-runtime -t "runtime-token" --acl "myapp/**:read" +phoenix agent create myapp-deployer -t "deploy-token" --acl "myapp/**:read,write" ``` +`myapp/**` grants access to everything under `myapp/`, including nested paths; +`myapp/*` would match one level only. See +[ACL path patterns](authentication.md#acl-path-patterns). + For production, prefer mTLS certs over bearer tokens. See [Authentication](authentication.md) for mTLS setup. diff --git a/docs/multi-agent-setup.md b/docs/multi-agent-setup.md index 9809993..2cf54b4 100644 --- a/docs/multi-agent-setup.md +++ b/docs/multi-agent-setup.md @@ -16,11 +16,11 @@ should be encrypted per-agent even in transit. ```bash phoenix agent create builder \ -t "$(openssl rand -hex 32)" \ - --acl "build/*:read" + --acl "build/**:read" phoenix agent create deployer \ -t "$(openssl rand -hex 32)" \ - --acl "deploy/*:read;infra/*:read" + --acl "deploy/**:read;infra/**:read" ``` ### 2. Generate sealed key pairs @@ -38,15 +38,15 @@ phoenix keypair generate deployer -o /etc/phoenix/keys/deployer.seal.key ```json { "attestation": { - "build/*": { + "build/**": { "require_sealed": true, "allow_unseal": true }, - "deploy/*": { + "deploy/**": { "require_sealed": true, "allow_unseal": false }, - "infra/*": { + "infra/**": { "require_sealed": true, "allow_unseal": false } @@ -54,8 +54,8 @@ phoenix keypair generate deployer -o /etc/phoenix/keys/deployer.seal.key } ``` -- `build/*` allows MCP unseal (builder may need values in tool output) -- `deploy/*` and `infra/*` deny unseal — values can only be consumed via +- `build/**` allows MCP unseal (builder may need values in tool output) +- `deploy/**` and `infra/**` deny unseal — values can only be consumed via `phoenix exec` See [Policy and Attestation](policy-and-attestation.md) for the full policy diff --git a/docs/policy-and-attestation.md b/docs/policy-and-attestation.md index 51c01e8..ec6bc4a 100644 --- a/docs/policy-and-attestation.md +++ b/docs/policy-and-attestation.md @@ -7,15 +7,15 @@ Phoenix evaluates policy per path. Different secrets can require different proof ```json { "attestation": { - "dev/*": { + "dev/**": { "require_mtls": false, "deny_bearer": false }, - "staging/*": { + "staging/**": { "require_mtls": true, "source_ip": ["192.168.0.0/24"] }, - "production/*": { + "production/**": { "require_mtls": true, "deny_bearer": true, "source_ip": ["192.168.0.110", "192.168.0.115"], @@ -25,9 +25,9 @@ Phoenix evaluates policy per path. Different secrets can require different proof } ``` -- `dev/*` — any valid credential works -- `staging/*` — must use mTLS and come from the expected network -- `production/*` — must use a specific certificate from a specific IP, with no bearer fallback +- `dev/**` — any valid credential works +- `staging/**` — must use mTLS and come from the expected network +- `production/**` — must use a specific certificate from a specific IP, with no bearer fallback Add the policy file in server config: diff --git a/docs/reference-only-enforcement-design.md b/docs/reference-only-enforcement-design.md index 20d5e78..43ac7e7 100644 --- a/docs/reference-only-enforcement-design.md +++ b/docs/reference-only-enforcement-design.md @@ -25,7 +25,7 @@ Within each `attestation` path rule: ```json { "attestation": { - "production/*": { + "production/**": { "deny_operations": ["get", "resolve"] } } @@ -37,7 +37,7 @@ Alternative form (mutually exclusive with `deny_operations`): ```json { "attestation": { - "production/*": { + "production/**": { "allow_operations": ["resolve"] } } diff --git a/docs/sealed-responses.md b/docs/sealed-responses.md index e47c0dc..f2cb47c 100644 --- a/docs/sealed-responses.md +++ b/docs/sealed-responses.md @@ -148,7 +148,7 @@ valid `X-Phoenix-Seal-Key` header are denied: ```json { "attestation": { - "production/*": { + "production/**": { "require_sealed": true } } @@ -164,11 +164,11 @@ the agent must pass them to `phoenix exec` for injection: ```json { "attestation": { - "production/*": { + "production/**": { "require_sealed": true, "allow_unseal": false }, - "dev/*": { + "dev/**": { "allow_unseal": true } } diff --git a/docs/session-identity.md b/docs/session-identity.md index 5222fb0..1c1023f 100644 --- a/docs/session-identity.md +++ b/docs/session-identity.md @@ -33,12 +33,12 @@ Enable sessions in your server config: "ttl": "1h", "roles": { "dev": { - "namespaces": ["dev/*", "staging/*"], + "namespaces": ["dev/**", "staging/**"], "actions": ["list", "read_value"], "bootstrap_trust": ["bearer"] }, "deploy": { - "namespaces": ["prod/*"], + "namespaces": ["prod/**"], "actions": ["list", "read_value"], "bootstrap_trust": ["mtls", "bearer"], "require_seal_key": true, From bfa79daffcba894f9b1471515aac31f135b2ca14 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Mon, 20 Jul 2026 12:53:04 -0400 Subject: [PATCH 11/11] =?UTF-8?q?fix(lint):=20resolve=20staticcheck=20find?= =?UTF-8?q?ings=20=E2=80=94=20unused=20errs=20init,=20unused=20audit=20hel?= =?UTF-8?q?per,=20escaped=20Unicode=20test=20literals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- cmd/phoenix/main.go | 2 +- internal/api/api_test.go | 8 ++++---- internal/audit/audit.go | 5 ----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cmd/phoenix/main.go b/cmd/phoenix/main.go index eb3adda..41c4633 100644 --- a/cmd/phoenix/main.go +++ b/cmd/phoenix/main.go @@ -1138,7 +1138,7 @@ func cmdOpenClawExecProvider(args []string) error { } values := make(map[string]string) - errs := make(map[string]string) + var errs map[string]string if sealPrivKey != nil { var result struct { SealedValues map[string]interface{} `json:"sealed_values"` diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 31e4993..e28348a 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1136,10 +1136,10 @@ func TestSanitizeOpenClawAuditMetadataValueStripsFormatControls(t *testing.T) { in string want string }{ - {name: "rtl-override", in: "admin‮nimda", want: "adminnimda"}, - {name: "zero-width-space-joiner", in: "ag​ent‍", want: "agent"}, - {name: "bidi-isolates", in: "⁦spoof⁩", want: "spoof"}, - {name: "only-format-chars", in: "‮​", want: ""}, + {name: "rtl-override", in: "admin\u202enimda", want: "adminnimda"}, + {name: "zero-width-space-joiner", in: "ag\u200bent\u200d", want: "agent"}, + {name: "bidi-isolates", in: "\u2066spoof\u2069", want: "spoof"}, + {name: "only-format-chars", in: "\u202e\u200b", want: ""}, {name: "plain", in: "openclaw-agent", want: "openclaw-agent"}, } for _, tc := range cases { diff --git a/internal/audit/audit.go b/internal/audit/audit.go index fa64dbc..6527399 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -55,11 +55,6 @@ func NewWriterLogger(w io.Writer) *Logger { } } -// log writes a low-level audit entry. -func (l *Logger) log(agent, action, path, status, ip, reason, sessionID string, sealed bool) error { - return l.logWithMetadata(agent, action, path, status, ip, reason, sessionID, sealed, nil) -} - // logWithMetadata writes a low-level audit entry with optional sanitized metadata. func (l *Logger) logWithMetadata(agent, action, path, status, ip, reason, sessionID string, sealed bool, metadata map[string]string) error { entry := Entry{