Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Release Notes

## v0.16.0 (2026-08-16)

### 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
Expand Down
65 changes: 51 additions & 14 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/`.
Expand Down
57 changes: 40 additions & 17 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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 ""
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -818,7 +841,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
Expand Down
Loading