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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ All notable changes to mcp-audit are documented in this file.

### Added

- Terminal audit `outcome` values and UUIDv7 `audit_operation_id` correlation,
with an exactly-once operation finalizer shared by HTTP and stdio transports.
- Security invariants and release gates for v1.2.0.
- Installation cookbook with platform-specific notes in `INSTALL.md`.
- VS Code stdio configuration example under `examples/vscode/`.
- Claude Desktop stdio configuration example under `examples/claude-desktop/`.
Expand Down
2 changes: 1 addition & 1 deletion STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`:

### Audit entry JSON schema

The fields recorded for each audit entry (`id`, `timestamp`, `direction`, `transport`, `method`, `request_id`, `tool_name`, `params`, `result`, `error`, `duration_ms`, `client_id`, `server_id`, `signature`) keep their names and types. New fields may be added in MINOR releases. Existing fields are not removed or renamed without a MAJOR bump.
The fields recorded for each audit entry (`id`, `timestamp`, `audit_operation_id`, `outcome`, `direction`, `transport`, `method`, `request_id`, `tool_name`, `params`, `result`, `error`, `duration_ms`, `client_id`, `server_id`, `signature`) keep their names and types. New fields may be added in MINOR releases. Existing fields are not removed or renamed without a MAJOR bump.

The signature is computed over `id + timestamp + method + tool_name + params`. Changing the signed field set requires a MAJOR bump because it invalidates existing signatures.

Expand Down
56 changes: 56 additions & 0 deletions docs/V1.2_SECURITY_INVARIANTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# v1.2 security invariants

This document defines the release gates for `mcp-audit` v1.2.0. They are target
invariants for the completed release; an individual change may introduce the
required primitives before every transport enforces them.

## Accepted requests have one terminal audit outcome

Every operation accepted by the proxy receives an `audit_operation_id` when it
enters the proxy and produces exactly one terminal audit entry. JSON-RPC
`request_id` remains the protocol identifier and is not assumed to be globally
unique.

The supported terminal outcomes are:

- `success`
- `denied`
- `rate_limited`
- `upstream_error`
- `timeout`
- `client_disconnect`
- `malformed_upstream_response`
- `cancelled`
- `internal_error`

Audit failures never cause the proxy to drop or modify a message. They are
reported independently so transport behavior remains transparent.

## Enabled signing is fail-closed

When `audit.sign` is `true`, configuration validation must establish that a
non-empty signing secret is available before any listener, dashboard, proxy, or
upstream process starts. Otherwise the process exits with a configuration error.

## Integrity v2 protects the complete critical record

Integrity v2 uses a deterministic, versioned representation and authenticates
all integrity-sensitive audit fields. The legacy `signature` field and its v1
verification semantics remain unchanged for compatibility.

Changing any protected field, including identity, direction, result, error, or
terminal outcome, must invalidate Integrity v2.

## HTTP policy identity is explicit

An identity used for HTTP policy evaluation comes from either an authenticated
principal or an explicitly configured static principal. Request-controlled
identity data is never trusted implicitly.

## MCP metadata is internally consistent

When MCP metadata is present in both HTTP headers and the JSON-RPC payload, the
method and object name must match. A mismatch is rejected before forwarding.

For example, `Mcp-Method: tools/call` and `Mcp-Name: delete_file` must agree
with the body method and `params.name`.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.22
toolchain go1.26.4

require (
github.com/google/uuid v1.6.0
github.com/oklog/ulid/v2 v2.1.1
github.com/prometheus/client_golang v1.22.0
github.com/spf13/viper v1.18.2
Expand All @@ -17,7 +18,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/magiconair/properties v1.8.7 // indirect
Expand Down
63 changes: 49 additions & 14 deletions internal/audit/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ const DirectionClientToServer = "client→server"
// DirectionServerToClient names server-to-client audit direction.
const DirectionServerToClient = "server→client"

// Outcome is the terminal state of an accepted audit operation.
type Outcome string

const (
OutcomeSuccess Outcome = "success"
OutcomeDenied Outcome = "denied"
OutcomeRateLimited Outcome = "rate_limited"
OutcomeUpstreamError Outcome = "upstream_error"
OutcomeTimeout Outcome = "timeout"
OutcomeClientDisconnect Outcome = "client_disconnect"
OutcomeMalformedUpstreamResponse Outcome = "malformed_upstream_response"
OutcomeCancelled Outcome = "cancelled"
OutcomeInternalError Outcome = "internal_error"
)

// Valid reports whether outcome is a supported terminal state.
func (o Outcome) Valid() bool {
switch o {
case OutcomeSuccess,
OutcomeDenied,
OutcomeRateLimited,
OutcomeUpstreamError,
OutcomeTimeout,
OutcomeClientDisconnect,
OutcomeMalformedUpstreamResponse,
OutcomeCancelled,
OutcomeInternalError:
return true
default:
return false
}
}

// RPCError represents a JSON-RPC error object.
type RPCError struct {
Code int `json:"code"`
Expand All @@ -26,20 +59,22 @@ type RPCError struct {

// Entry is a single audited JSON-RPC exchange or message.
type Entry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Direction string `json:"direction"`
Transport string `json:"transport"`
Method string `json:"method"`
RequestID string `json:"request_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
ClientID string `json:"client_id"`
ServerID string `json:"server_id"`
Signature string `json:"signature"`
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
AuditOperationID string `json:"audit_operation_id,omitempty"`
Outcome Outcome `json:"outcome,omitempty"`
Direction string `json:"direction"`
Transport string `json:"transport"`
Method string `json:"method"`
RequestID string `json:"request_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
ClientID string `json:"client_id"`
ServerID string `json:"server_id"`
Signature string `json:"signature"`
}

// Store persists and queries audit entries.
Expand Down
111 changes: 111 additions & 0 deletions internal/audit/operation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package audit

import (
"encoding/json"
"errors"
"fmt"
"sync"
"time"

"github.com/google/uuid"
)

var (
// ErrOperationFinalized is returned when a second terminal event is attempted.
ErrOperationFinalized = errors.New("audit: operation already finalized")
// ErrInvalidOutcome is returned for a non-terminal or unknown outcome.
ErrInvalidOutcome = errors.New("audit: invalid terminal outcome")
)

// Completion contains the response-side data used to finalize an operation.
type Completion struct {
Outcome Outcome
Direction string
Result json.RawMessage
Error *RPCError
}

// Operation owns the exactly-once terminal audit event for one accepted request.
type Operation struct {
mu sync.Mutex
logger *Logger
entry Entry
startedAt time.Time
finalized bool
}

// NewOperation starts an operation and assigns it a UUIDv7 correlation ID.
func NewOperation(logger *Logger, entry Entry, startedAt time.Time) (*Operation, error) {
if logger == nil {
return nil, fmt.Errorf("audit: operation: logger is required")
}
operationID, err := uuid.NewV7()
if err != nil {
return nil, fmt.Errorf("audit: operation: generate UUIDv7: %w", err)
}
if startedAt.IsZero() {
startedAt = time.Now()
}
entry.AuditOperationID = operationID.String()
entry.Params = cloneRawMessage(entry.Params)
entry.Result = nil
entry.Error = nil
entry.Outcome = ""
return &Operation{
logger: logger,
entry: entry,
startedAt: startedAt,
}, nil
}

// ID returns the stable correlation ID assigned when the operation was accepted.
func (o *Operation) ID() string {
if o == nil {
return ""
}
return o.entry.AuditOperationID
}

// Finalize records the operation's terminal event exactly once.
func (o *Operation) Finalize(completion Completion) error {
if o == nil {
return fmt.Errorf("audit: operation: nil operation")
}
if !completion.Outcome.Valid() {
return fmt.Errorf("%w: %q", ErrInvalidOutcome, completion.Outcome)
}

o.mu.Lock()
defer o.mu.Unlock()
if o.finalized {
return ErrOperationFinalized
}

entry := o.entry
entry.Outcome = completion.Outcome
entry.Direction = completion.Direction
entry.Result = cloneRawMessage(completion.Result)
entry.Error = cloneRPCError(completion.Error)
entry.DurationMs = time.Since(o.startedAt).Milliseconds()
if entry.DurationMs < 0 {
entry.DurationMs = 0
}
if err := o.logger.Record(entry); err != nil {
return err
}
o.finalized = true
return nil
}

func cloneRawMessage(raw json.RawMessage) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}

func cloneRPCError(rpcErr *RPCError) *RPCError {
if rpcErr == nil {
return nil
}
cloned := *rpcErr
cloned.Data = cloneRawMessage(rpcErr.Data)
return &cloned
}
Loading
Loading