From 3a2ff7b8b13ee109c3102726b6fd5be19f725218 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Mon, 27 Jul 2026 02:07:40 -0400 Subject: [PATCH 1/4] fix(api): return an empty paths array instead of null on list GET /v1/secrets built the visible-path list with `var visible []string`, which marshals as `"paths": null` when the caller can see nothing. Clients that iterate the array unconditionally had to special-case the null. Initialize the slice so an empty result serializes as `"paths": []`. Co-Authored-By: Claude Opus 5 --- internal/api/api.go | 2 +- internal/api/api_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index 3c3f912..37e0872 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -818,7 +818,7 @@ func (s *Server) handleGetSecret(w http.ResponseWriter, r *http.Request) { jsonError(w, "internal error", http.StatusInternalServerError) return } - var visible []string + visible := []string{} for _, p := range allPaths { if s.dataAccessReason(info, p, "list", acl.ActionList) != "" { continue diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e28348a..337a58e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -281,6 +281,36 @@ func TestListSecrets(t *testing.T) { } } +// An empty result must marshal as [] rather than null so clients can iterate +// the array unconditionally. +func TestListSecretsEmptyResultIsEmptyArrayNotNull(t *testing.T) { + srv, adminToken := setupTestServer(t) + + // Only "other/*" exists, which the reader has no permission to see. + body, _ := json.Marshal(setSecretRequest{Value: "v"}) + req := httptest.NewRequest("PUT", "/v1/secrets/other/c", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + req = httptest.NewRequest("GET", "/v1/secrets/", nil) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + + raw := w.Body.String() + if strings.Contains(raw, `"paths":null`) { + t.Fatalf("empty list marshalled as null: %s", raw) + } + var resp map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatalf("decoding list response: %v", err) + } + if got := string(resp["paths"]); got != "[]" { + t.Fatalf("paths = %s, want []", got) + } +} + func TestNotFound(t *testing.T) { srv, adminToken := setupTestServer(t) From f7e79e83cdb5a2a9083c4880312347ad3068a75f Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Mon, 27 Jul 2026 02:08:04 -0400 Subject: [PATCH 2/4] feat(api): capture X-Hermes-* headers as audit-only caller metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes-phoenix plugin labels its requests with X-Hermes-Profile, Session-Id, Channel, Tool, and Task-Id so an audit entry can be traced back to the conversation, task, or tool that triggered it. Phoenix ignored the family entirely. Generalize the OpenClaw-specific header table into one platform-neutral caller-metadata allowlist and add the Hermes family to it. The OpenClaw mappings, the openclaw.* audit keys, the 256-byte cap, and the sanitization rules are unchanged — only the symbol names moved. The allowlist stays compiled in rather than operator-configurable. A config-driven header map would let an operator route Authorization, Cookie, or a session key into the plaintext audit log; nothing needs that flexibility today, and adding a platform is five lines here. These values are untrusted hints. They reach only the six audit wrappers and never authentication, ACL, policy, session identity, role mapping, or sealing. X-Phoenix-Tool stays out of the table: it is an attestation policy input, and the audit-only X-Hermes-Tool hint must not be able to satisfy allowed_tools in its place. Tests cover capture with exact key sets, exclusion of unlisted X-Hermes-* headers, absent headers, oversize and control-character sanitization, ACL and attestation spoof resistance, both families on one request, and a self-check that fails the build if a future entry is credential-shaped, reserved, or duplicated. Co-Authored-By: Claude Opus 5 --- internal/api/api.go | 55 +++++-- internal/api/api_test.go | 337 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 374 insertions(+), 18 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 37e0872..eb067c5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,17 +36,40 @@ import ( // MaxRequestBodyBytes limits the size of request bodies to prevent DoS. const MaxRequestBodyBytes = 1 << 20 // 1 MB -const openClawAuditMetadataMaxValueLen = 256 +const callerAuditMetadataMaxValueLen = 256 -var openClawAuditHeaders = []struct { +// callerAuditHeaders is the exhaustive allowlist of caller-supplied headers +// captured as audit metadata, one family per integrating agent platform. +// Headers outside this table are never recorded. +// +// Every value here is an untrusted hint from the client: a caller may set any +// of them to anything. They exist so an audit entry can be traced back to the +// conversation, task, or tool that triggered it — never to decide anything. +// Adding a header to this table must not create a path into authentication, +// ACL, policy/attestation, session identity, role mapping, or sealing. +// +// In particular, X-Phoenix-Tool is deliberately absent: it is an attestation +// policy input read by attestFull, and must not be conflated with the +// audit-only X-Hermes-Tool hint. +// +// Session keys (e.g. X-OpenClaw-Session-Key) are credentials and must never +// appear here — capturing one would write it to the audit log in cleartext. +var callerAuditHeaders = []struct { header string key string }{ + // OpenClaw {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"}, + // Hermes + {header: "X-Hermes-Profile", key: "hermes.profile"}, + {header: "X-Hermes-Session-Id", key: "hermes.session_id"}, + {header: "X-Hermes-Channel", key: "hermes.channel"}, + {header: "X-Hermes-Tool", key: "hermes.tool"}, + {header: "X-Hermes-Task-Id", key: "hermes.task_id"}, } // Rate limiting constants for authentication attempts. @@ -480,16 +503,16 @@ func extractToken(r *http.Request) string { return "" } -// openClawAuditMetadata returns sanitized audit-only OpenClaw metadata hints. +// callerAuditMetadata returns sanitized audit-only caller 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 { +func callerAuditMetadata(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)) + metadata := make(map[string]string, len(callerAuditHeaders)) + for _, h := range callerAuditHeaders { + value := sanitizeCallerAuditMetadataValue(r.Header.Get(h.header)) if value != "" { metadata[h.key] = value } @@ -500,7 +523,7 @@ func openClawAuditMetadata(r *http.Request) map[string]string { return metadata } -func sanitizeOpenClawAuditMetadataValue(value string) string { +func sanitizeCallerAuditMetadataValue(value string) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -515,12 +538,12 @@ func sanitizeOpenClawAuditMetadataValue(value string) string { return r }, value) value = strings.Join(strings.Fields(value), " ") - if len(value) <= openClawAuditMetadataMaxValueLen { + if len(value) <= callerAuditMetadataMaxValueLen { return value } last := 0 for i := range value { - if i > openClawAuditMetadataMaxValueLen { + if i > callerAuditMetadataMaxValueLen { break } last = i @@ -595,27 +618,27 @@ func handleAuthError(w http.ResponseWriter, err error) { } func (s *Server) auditLogAllowed(r *http.Request, agent, action, path, ip string) { - s.logAudit(s.audit.LogAllowedWithMetadata(agent, action, path, ip, openClawAuditMetadata(r))) + s.logAudit(s.audit.LogAllowedWithMetadata(agent, action, path, ip, callerAuditMetadata(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))) + s.logAudit(s.audit.LogAllowedSealedWithMetadata(agent, action, path, ip, sealed, callerAuditMetadata(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))) + s.logAudit(s.audit.LogDeniedWithMetadata(agent, action, path, ip, reason, callerAuditMetadata(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))) + s.logAudit(s.audit.LogSessionAllowedWithMetadata(agent, action, path, ip, sessionID, callerAuditMetadata(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))) + s.logAudit(s.audit.LogSessionAllowedSealedWithMetadata(agent, action, path, ip, sessionID, sealed, callerAuditMetadata(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))) + s.logAudit(s.audit.LogSessionDeniedWithMetadata(agent, action, path, ip, reason, sessionID, callerAuditMetadata(r))) } // auditAllowed logs an allowed action, including session ID when a session was used. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 337a58e..9718b65 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1160,7 +1160,7 @@ func addSpoofedOpenClawHeaders(req *http.Request, rawSessionKey string) { req.Header.Set("X-OpenClaw-Role", "admin") } -func TestSanitizeOpenClawAuditMetadataValueStripsFormatControls(t *testing.T) { +func TestSanitizeCallerAuditMetadataValueStripsFormatControls(t *testing.T) { cases := []struct { name string in string @@ -1173,7 +1173,7 @@ func TestSanitizeOpenClawAuditMetadataValueStripsFormatControls(t *testing.T) { {name: "plain", in: "openclaw-agent", want: "openclaw-agent"}, } for _, tc := range cases { - if got := sanitizeOpenClawAuditMetadataValue(tc.in); got != tc.want { + if got := sanitizeCallerAuditMetadataValue(tc.in); got != tc.want { t.Errorf("%s: sanitize(%q) = %q, want %q", tc.name, tc.in, got, tc.want) } } @@ -1356,6 +1356,339 @@ func TestOpenClawSessionKeyDoesNotSatisfySealedPolicyOrLeak(t *testing.T) { } } +func addSpoofedHermesHeaders(req *http.Request) { + req.Header.Set("X-Hermes-Profile", "kit\u202emain") + req.Header.Set("X-Hermes-Session-Id", " hermes-session-77 ") + req.Header.Set("X-Hermes-Channel", "telegram\tdm") + req.Header.Set("X-Hermes-Tool", "phoenix_get") + req.Header.Set("X-Hermes-Task-Id", "task-104") + // Not in the allowlist — must be ignored entirely. + req.Header.Set("X-Hermes-Role", "admin") + req.Header.Set("X-Hermes-Agent", "admin") +} + +func TestHermesHeadersCapturedAsSanitizedAuditMetadataOnly(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "secret-val"}) + req := httptest.NewRequest("PUT", "/v1/secrets/test/hermes-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()) + } + + req = httptest.NewRequest("GET", "/v1/secrets/test/hermes-meta", nil) + req.Header.Set("Authorization", "Bearer reader-token") + addSpoofedHermesHeaders(req) + 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, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "test/hermes-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{ + "hermes.profile": "kitmain", + "hermes.session_id": "hermes-session-77", + "hermes.channel": "telegram dm", + "hermes.tool": "phoenix_get", + "hermes.task_id": "task-104", + } + 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) + } + } + // Unlisted X-Hermes-* headers must not be captured under any key. + for k, v := range entry.Metadata { + if v == "admin" { + t.Fatalf("unlisted Hermes header captured as metadata[%s]", k) + } + } +} + +// Missing Hermes headers must be harmless: no keys, no empty-string values. +func TestMissingHermesHeadersProduceNoMetadata(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "v"}) + req := httptest.NewRequest("PUT", "/v1/secrets/test/hermes-absent", 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()) + } + + // Only one Hermes header set; the other four are absent. + req = httptest.NewRequest("GET", "/v1/secrets/test/hermes-absent", nil) + req.Header.Set("Authorization", "Bearer reader-token") + req.Header.Set("X-Hermes-Channel", "cli") + 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, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "test/hermes-absent", "allowed", "") + if entry == nil { + t.Fatalf("missing read_value audit entry; got: %+v", entries) + } + if len(entry.Metadata) != 1 || entry.Metadata["hermes.channel"] != "cli" { + t.Fatalf("metadata = %+v, want only hermes.channel=cli", entry.Metadata) + } + + // A request with no caller headers at all must carry no metadata. + req = httptest.NewRequest("GET", "/v1/secrets/test/hermes-absent", nil) + req.Header.Set("Authorization", "Bearer reader-token") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("bare read: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := callerAuditMetadata(req); got != nil { + t.Fatalf("callerAuditMetadata with no headers = %+v, want nil", got) + } +} + +// Oversized and control-character values must be capped and sanitized. +func TestHermesHeaderValuesAreSanitizedAndCapped(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "v"}) + req := httptest.NewRequest("PUT", "/v1/secrets/test/hermes-oversize", 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/test/hermes-oversize", nil) + req.Header.Set("Authorization", "Bearer reader-token") + req.Header.Set("X-Hermes-Channel", strings.Repeat("x", 300)) + req.Header.Set("X-Hermes-Task-Id", "task\x00\x7f-42") + req.Header.Set("X-Hermes-Profile", "\u202e\u200b") + 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, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "test/hermes-oversize", "allowed", "") + if entry == nil { + t.Fatalf("missing read_value audit entry; got: %+v", entries) + } + if got := len(entry.Metadata["hermes.channel"]); got > callerAuditMetadataMaxValueLen { + t.Fatalf("hermes.channel length = %d, want <= %d", got, callerAuditMetadataMaxValueLen) + } + if got := entry.Metadata["hermes.task_id"]; got != "task -42" { + t.Fatalf("hermes.task_id = %q, want control chars converted to space", got) + } + // A value that is nothing but format controls sanitizes to empty and is dropped. + if _, ok := entry.Metadata["hermes.profile"]; ok { + t.Fatalf("hermes.profile = %q, want key absent", entry.Metadata["hermes.profile"]) + } +} + +// X-Hermes-Tool is an audit hint; X-Phoenix-Tool is the attestation input. +// A conflicting pair must not let the Hermes header satisfy tool policy. +func TestHermesToolHeaderDoesNotSpoofPhoenixToolAttestation(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "tool-guarded"}) + req := httptest.NewRequest("PUT", "/v1/secrets/locked/hermes-tool", 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/*":{"allowed_tools":["phoenix_resolve"]}}}`)) + if err != nil { + t.Fatalf("load policy: %v", err) + } + srv.SetPolicy(p) + + // Hermes claims the allowed tool; the real attestation header does not. + req = httptest.NewRequest("GET", "/v1/secrets/locked/hermes-tool", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("X-Hermes-Tool", "phoenix_resolve") + req.Header.Set("X-Phoenix-Tool", "wrong_tool") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("X-Hermes-Tool must not satisfy allowed_tools: got %d: %s", w.Code, w.Body.String()) + } + + entries, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "locked/hermes-tool", "denied", "attestation") + if entry == nil { + t.Fatalf("missing attestation denial audit entry; got: %+v", entries) + } + // The spoofed hint is still recorded as a hint, under the Hermes key only. + if got := entry.Metadata["hermes.tool"]; got != "phoenix_resolve" { + t.Fatalf("metadata hermes.tool = %q, want spoofed hint %q", got, "phoenix_resolve") + } + for k := range entry.Metadata { + if strings.HasPrefix(k, "phoenix.") { + t.Fatalf("X-Phoenix-Tool captured as caller metadata key %q", k) + } + } + + // The genuine attestation header still works on its own. + req = httptest.NewRequest("GET", "/v1/secrets/locked/hermes-tool", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("X-Hermes-Tool", "anything-at-all") + req.Header.Set("X-Phoenix-Tool", "phoenix_resolve") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("genuine X-Phoenix-Tool should be allowed: got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHermesHeadersDoNotBypassACL(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "admin-secret"}) + req := httptest.NewRequest("PUT", "/v1/secrets/admin-only/hermes", 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()) + } + + // reader may only read test/*; no Hermes header may change that. + req = httptest.NewRequest("GET", "/v1/secrets/admin-only/hermes", nil) + req.Header.Set("Authorization", "Bearer reader-token") + req.Header.Set("X-Hermes-Profile", "admin") + req.Header.Set("X-Hermes-Tool", "phoenix_get") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("spoofed Hermes headers should not bypass ACL: got %d: %s", w.Code, w.Body.String()) + } + + entries, _ := queryAPIAuditEntries(t, srv, adminToken) + entry := findAPIAuditEntry(entries, "read_value", "admin-only/hermes", "denied", "") + if entry == nil { + t.Fatalf("missing denial 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["hermes.profile"]; got != "admin" { + t.Fatalf("metadata hermes.profile = %q, want spoofed hint %q", got, "admin") + } +} + +// Both families on one request must coexist without interfering. +func TestOpenClawAndHermesFamiliesCoexist(t *testing.T) { + srv, adminToken := setupTestServer(t) + + body, _ := json.Marshal(setSecretRequest{Value: "v"}) + req := httptest.NewRequest("PUT", "/v1/secrets/test/both-families", 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_BOTH_FAMILIES_DO_NOT_LOG" + req = httptest.NewRequest("GET", "/v1/secrets/test/both-families", nil) + req.Header.Set("Authorization", "Bearer reader-token") + addSpoofedOpenClawHeaders(req, rawSessionKey) + addSpoofedHermesHeaders(req) + 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/both-families", "allowed", "") + if entry == nil { + t.Fatalf("missing read_value audit entry; got: %+v", entries) + } + 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", + "hermes.profile": "kitmain", + "hermes.session_id": "hermes-session-77", + "hermes.channel": "telegram dm", + "hermes.tool": "phoenix_get", + "hermes.task_id": "task-104", + } + 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) + } + } +} + +// Guards the allowlist itself: no credential-bearing or reserved header may be +// added to callerAuditHeaders, and every entry must be a namespaced X- header. +func TestCallerAuditHeadersAllowlistIsSafe(t *testing.T) { + forbiddenSubstrings := []string{ + "authorization", "cookie", "credential", "password", + "private", "secret", "token", "key", + } + seenHeader := map[string]bool{} + seenKey := map[string]bool{} + for _, h := range callerAuditHeaders { + lower := strings.ToLower(h.header) + if !strings.HasPrefix(lower, "x-") { + t.Errorf("header %q must be a namespaced X- header", h.header) + } + if strings.HasPrefix(lower, "x-phoenix-") { + t.Errorf("header %q is reserved for Phoenix attestation inputs", h.header) + } + for _, bad := range forbiddenSubstrings { + if strings.Contains(lower, bad) { + t.Errorf("header %q looks credential-bearing (%q) and must not be audited", h.header, bad) + } + } + if seenHeader[lower] { + t.Errorf("duplicate header %q", h.header) + } + seenHeader[lower] = true + if h.key == "" || strings.ToLower(h.key) != h.key { + t.Errorf("audit key %q must be lowercase and non-empty", h.key) + } + if seenKey[h.key] { + t.Errorf("duplicate audit key %q", h.key) + } + seenKey[h.key] = true + } +} + func TestResolveEmptyRefs(t *testing.T) { srv, adminToken := setupTestServer(t) From fe96b9f1d1a1b22a78cc6871e83499a26d1dcd05 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Mon, 27 Jul 2026 02:08:38 -0400 Subject: [PATCH 3/4] docs(release): document caller metadata headers, add scenario 20, bump to 0.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the OpenClaw-only audit-header subsection to a top-level "Caller metadata audit headers" section covering both families, with the untrusted/audit-only contract, the X-Phoenix-Tool distinction, and a note that the allowlist is compiled in so no configuration can route a credential header into the audit log. Add e2e scenario 20 for the Hermes contract: capture and sanitization, the 256-byte cap, exclusion of unlisted and credential-shaped headers, ACL spoof resistance, and X-Hermes-Tool failing allowed_tools where a genuine X-Phoenix-Tool succeeds. Scenario 19 is left reserved for secure-transport coverage. Also warn in the e2e README that E2E_BIN_DIR binaries are reused unless E2E_REBUILD=1 — stale binaries make new assertions fail as if the server feature were broken. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 26 ++++++++ docs/integrations.md | 65 +++++++++++++++---- internal/version/version.go | 2 +- tests/e2e/README.md | 7 +- .../e2e/scenario-20-hermes-audit-metadata.sh | 59 +++++++++++++++++ 5 files changed, 143 insertions(+), 16 deletions(-) create mode 100755 tests/e2e/scenario-20-hermes-audit-metadata.sh diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 505b7df..afea872 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,31 @@ # Release Notes +## v0.16.0 (2026-07-27) + +### Hermes Caller Metadata Audit Capture + +- Captures the sanitized `X-Hermes-Profile`, `X-Hermes-Session-Id`, + `X-Hermes-Channel`, `X-Hermes-Tool`, and `X-Hermes-Task-Id` headers as + audit-only metadata under `hermes.*` keys, for the `hermes-phoenix` plugin. +- Generalized the OpenClaw-specific header table into a single + platform-neutral caller-metadata allowlist. The OpenClaw family, its + `openclaw.*` audit keys, the 256-byte cap, and the sanitization rules are + unchanged. +- The allowlist stays compiled in rather than operator-configurable, so no + configuration change can route a credential header into the audit log. +- `X-Phoenix-Tool` remains an attestation policy input and is never recorded + as caller metadata; the audit-only `X-Hermes-Tool` hint cannot satisfy + `allowed_tools`. +- Added unit coverage for Hermes capture, absent headers, oversize/control + sanitization, ACL and attestation spoof resistance, both families on one + request, and an allowlist self-check that rejects credential-bearing or + `X-Phoenix-*` entries. Added e2e scenario 20. +- `GET /v1/secrets` list responses now return `"paths": []` instead of + `"paths": null` when no paths are visible. + +> `v0.16.0` is the minimum Phoenix version for `hermes-phoenix` first-class +> audit metadata. Older servers ignore the `X-Hermes-*` family entirely. + ## v0.15.2 (2026-07-12) ### OpenClaw Exec Provider Merge-Review Fixes diff --git a/docs/integrations.md b/docs/integrations.md index 39d0c81..4f91cda 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -193,20 +193,9 @@ 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. +The server records five `X-OpenClaw-*` headers as untrusted audit hints. See +[Caller metadata audit headers](#caller-metadata-audit-headers) for the full +list and the guarantees that apply to it. ### 2. Plugin tools for agent/tool runtime access @@ -266,6 +255,54 @@ volumes: For mTLS instead of bearer tokens, mount certs into the OpenClaw container and set `PHOENIX_CA_CERT`, `PHOENIX_CLIENT_CERT`, and `PHOENIX_CLIENT_KEY`. +## Caller metadata audit headers + +An agent platform can label its Phoenix requests so an audit entry can be traced +back to the conversation, task, or tool that triggered it. The server captures +one fixed header family per supported platform: + +| Header | Audit metadata key | +|---|---| +| `X-OpenClaw-Agent` | `openclaw.agent` | +| `X-OpenClaw-Session-Id` | `openclaw.session_id` | +| `X-OpenClaw-Channel` | `openclaw.channel` | +| `X-OpenClaw-Requester-Sender` | `openclaw.requester_sender` | +| `X-OpenClaw-Sender-Is-Owner` | `openclaw.sender_is_owner` | +| `X-Hermes-Profile` | `hermes.profile` | +| `X-Hermes-Session-Id` | `hermes.session_id` | +| `X-Hermes-Channel` | `hermes.channel` | +| `X-Hermes-Tool` | `hermes.tool` | +| `X-Hermes-Task-Id` | `hermes.task_id` | + +The `hermes-phoenix` plugin emits the `X-Hermes-*` family. Both families may +appear on the same request; they do not interact. + +**These values are untrusted and audit-only.** Any caller can set them to +anything, so treat them as labels, not identity: + +- They have zero effect on authentication, ACL authorization, attestation + policy, session identity, role mapping, or sealed-response decisions. + Spoofing them cannot elevate access. +- The audited actor is always the authenticated Phoenix agent. A spoofed + `X-Hermes-Profile: admin` is recorded as a hint under `hermes.profile` while + the `agent` field stays whatever the credential proved. +- The list above is exhaustive. Any other header — including other `X-Hermes-*` + or `X-OpenClaw-*` names — is ignored entirely. +- Session keys (`X-OpenClaw-Session-Key`) are credentials. They are deliberately + never captured and never written to audit logs. +- Each value is sanitized (format characters removed, control characters + converted to spaces, whitespace collapsed) and capped at 256 bytes. Values + that sanitize to empty are dropped rather than stored blank. + +`X-Phoenix-Tool` is **not** part of this mechanism. It is an attestation policy +input evaluated against `allowed_tools`/`deny_tools`, so it is never recorded as +caller metadata. `X-Hermes-Tool` is the audit-only hint of the same idea; a +request may send both, and only `X-Phoenix-Tool` affects the access decision. + +Adding a platform is a code change to the allowlist in `internal/api/api.go`, +not configuration — an operator cannot map arbitrary headers, so a credential +header can never be routed into the audit log by misconfiguration. + ## Go SDK The Go SDK is included in the repository at `sdk/go/phoenix/`. diff --git a/internal/version/version.go b/internal/version/version.go index c8f90c3..ede160a 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.2" +var Version = "0.16.0" diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 97ae07a..32e1b8a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -10,7 +10,10 @@ 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. 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. +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. + +> Binaries in `E2E_BIN_DIR` are reused whenever they already exist. After changing server behavior, run with `E2E_REBUILD=1` — otherwise scenarios silently test the previously built binary and a new assertion fails as if the feature were broken. + 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 @@ -44,5 +47,7 @@ The core suite needs Bash, Go, curl, Python 3, and jq. Set `GO_BIN` for a non-PA | 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 | +| 19 | — | reserved for secure-transport coverage | +| 20 | Hermes caller metadata: capture, sanitization/cap, unlisted-header exclusion, ACL spoof resistance, `X-Hermes-Tool` vs `X-Phoenix-Tool` | 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/scenario-20-hermes-audit-metadata.sh b/tests/e2e/scenario-20-hermes-audit-metadata.sh new file mode 100755 index 0000000..3ac3480 --- /dev/null +++ b/tests/e2e/scenario-20-hermes-audit-metadata.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +scenario_start 20 hermes-audit-metadata +init_scenario 20 +start_server +phoenix_admin set hermesmeta/allowed -v synthetic-hermes-audit >/dev/null +phoenix_admin set restricted/key -v synthetic-restricted >/dev/null +phoenix_admin set tooled/key -v synthetic-tooled >/dev/null +phoenix_admin agent create hermes-reader -t synthetic-hermes-reader --acl 'hermesmeta/*:read;tooled/*:read' >/dev/null +long="$(python3 -c 'print("x"*300)')"; formatted=$'kit\u202Emain'; sentinel=synthetic-hermes-key-must-not-log + +# All five Hermes headers, plus unlisted and credential-shaped headers that must be ignored. +curl -fsS "$PHOENIX_SERVER/v1/secrets/hermesmeta/allowed" -H 'Authorization: Bearer synthetic-hermes-reader' \ + -H "X-Hermes-Profile: $formatted" -H 'X-Hermes-Session-Id: hermes-session-77' -H "X-Hermes-Channel: $long" \ + -H 'X-Hermes-Tool: phoenix_get' -H 'X-Hermes-Task-Id: task-104' \ + -H "X-Hermes-Session-Key: $sentinel" -H 'X-Hermes-Role: admin' -H 'X-Hermes-Agent: admin' >/dev/null + +# Spoofed Hermes identity must not widen ACL. +code="$(curl -sS -o "$RUN_DIR/spoof" -w '%{http_code}' "$PHOENIX_SERVER/v1/secrets/restricted/key" \ + -H 'Authorization: Bearer synthetic-hermes-reader' -H 'X-Hermes-Profile: admin' -H 'X-Hermes-Tool: phoenix_get')" +assert_eq "$code" 403 "spoofed authorization" + +audit="$(api_admin GET /v1/audit)" +assert_not_contains "$audit" "$sentinel" "hermes session key leaked" +entry="$(jq -c '[.entries[] | select(.path=="hermesmeta/allowed" and .action=="read_value")][-1]' <<<"$audit")" +assert_eq "$(jq -r .agent <<<"$entry")" hermes-reader "authenticated agent preserved" +assert_eq "$(jq -r '.metadata["hermes.profile"]' <<<"$entry")" kitmain "format controls stripped" +assert_eq "$(jq -r '.metadata["hermes.session_id"]' <<<"$entry")" hermes-session-77 +assert_eq "$(jq -r '.metadata["hermes.channel"] | length' <<<"$entry")" 256 "256-char cap" +assert_eq "$(jq -r '.metadata["hermes.tool"]' <<<"$entry")" phoenix_get +assert_eq "$(jq -r '.metadata["hermes.task_id"]' <<<"$entry")" task-104 +assert_eq "$(jq -r '.metadata | keys | length' <<<"$entry")" 5 "exactly the five allowlisted keys" +for k in hermes.session_key hermes.role hermes.agent; do + [[ "$(jq -r --arg k "$k" '.metadata | has($k)' <<<"$entry")" == false ]] || die "unlisted header captured: $k" +done +deny="$(jq -c '[.entries[] | select(.path=="restricted/key" and .status=="denied")][-1]' <<<"$audit")" +assert_eq "$(jq -r .agent <<<"$deny")" hermes-reader "denial records real agent" +assert_eq "$(jq -r '.metadata["hermes.profile"]' <<<"$deny")" admin "spoof recorded as hint only" + +# X-Hermes-Tool is an audit hint; X-Phoenix-Tool is the attestation input. +stop_server +cat >"$RUN_DIR/policy.json" <<'JSON' +{"attestation":{"tooled/*":{"allowed_tools":["phoenix_resolve"]}}} +JSON +config_patch "d['policy']={'path':'$RUN_DIR/policy.json'}" +start_server +code="$(curl -sS -o "$RUN_DIR/toolspoof" -w '%{http_code}' "$PHOENIX_SERVER/v1/secrets/tooled/key" \ + -H 'Authorization: Bearer synthetic-hermes-reader' -H 'X-Hermes-Tool: phoenix_resolve' -H 'X-Phoenix-Tool: wrong_tool')" +assert_eq "$code" 403 "X-Hermes-Tool must not satisfy allowed_tools" +body="$(curl -fsS "$PHOENIX_SERVER/v1/secrets/tooled/key" \ + -H 'Authorization: Bearer synthetic-hermes-reader' -H 'X-Hermes-Tool: anything-at-all' -H 'X-Phoenix-Tool: phoenix_resolve')" +assert_eq "$(jq -r .value <<<"$body")" synthetic-tooled "genuine X-Phoenix-Tool still attests" +audit="$(api_admin GET /v1/audit)" +tooldeny="$(jq -c '[.entries[] | select(.path=="tooled/key" and .status=="denied")][-1]' <<<"$audit")" +assert_eq "$(jq -r '.metadata["hermes.tool"]' <<<"$tooldeny")" phoenix_resolve "hermes tool recorded as hint" +[[ "$(jq -r '[.entries[].metadata // {} | keys[]] | map(select(startswith("phoenix."))) | length' <<<"$audit")" == 0 ]] \ + || die "X-Phoenix-Tool captured as caller metadata" +ok "Scenario 20 PASS" From 673b7ca342aa7eaae7b4f431d0b939d6127f56b1 Mon Sep 17 00:00:00 2001 From: "Aaron.ClaudeContainer" Date: Sun, 16 Aug 2026 07:40:50 -0400 Subject: [PATCH 4/4] docs(release): set the v0.16.0 release date The section was dated when the work landed, three weeks before release. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index afea872..3a6ac52 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ # Release Notes -## v0.16.0 (2026-07-27) +## v0.16.0 (2026-08-16) ### Hermes Caller Metadata Audit Capture