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
170 changes: 170 additions & 0 deletions audit/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright 2025 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package audit writes a structured, append-only JSONL activity log - one
// self-contained event per line - so an external tool can tail OpenAgent's tool
// activity read-only, without reading the database. It is a pure additional
// sink: a failure here never blocks or fails the operation being audited.
//
// The log lives next to the binary (the same directory strategy the SQLite
// database uses), overridable with OPENAGENT_AUDIT_DIR. One file per session,
// so a reader can use the file name as the session key and never has to
// untangle interleaved sessions.
package audit

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
)

const timeFormat = "2006-01-02T15:04:05.000Z07:00"

// Event is one audit line. Fields are omitted when empty so the format stays
// small and forward-compatible: a reader ignores fields it does not know.
type Event struct {
Timestamp string `json:"timestamp"`
SessionID string `json:"sessionId,omitempty"`
Type string `json:"type"`
Tool string `json:"tool,omitempty"`
Server string `json:"server,omitempty"`
Model string `json:"model,omitempty"`
ArgumentsLength int `json:"argumentsLength,omitempty"`
Outcome string `json:"outcome,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
// Effect, Reason and Rule carry the guard verdict once the guard is wired
// into the tool path; empty until then.
Effect string `json:"effect,omitempty"`
Reason string `json:"reason,omitempty"`
Rule string `json:"rule,omitempty"`
}

// queueSize bounds how many events may be waiting to be written. It is generous
// because each event is tiny; if it is ever exceeded, events are dropped rather
// than allowed to block a tool call.
const queueSize = 4096

// queued is one unit of work for the background writer. A marker carries only
// done (line nil) and lets flush wait until everything before it is written.
type queued struct {
line []byte
session string
done chan struct{}
}

var (
queue chan queued
startOnce sync.Once
)

// Record appends one event to its session's audit file. It is best-effort and
// non-blocking: the event is handed to a background writer, so a slow or stalled
// audit directory (for example an OPENAGENT_AUDIT_DIR on a network mount) can
// never slow down or fail the tool call it is recording. If the writer cannot
// keep up, events are dropped rather than allowed to block.
func Record(event Event) {
if event.Type == "" {
return
}
event.Timestamp = time.Now().UTC().Format(timeFormat)

line, err := json.Marshal(event)
if err != nil {
return
}

startOnce.Do(startWriter)
select {
case queue <- queued{line: append(line, '\n'), session: sanitizeSession(event.SessionID)}:
default:
// Queue full: drop rather than block. Auditing is a sidecar and must
// never hold up the operation it records.
}
}

func startWriter() {
queue = make(chan queued, queueSize)
go func() {
for item := range queue {
if item.line != nil {
writeLine(item.session, item.line)
}
if item.done != nil {
close(item.done)
}
}
}()
}

// writeLine performs the actual disk append, off the caller's goroutine. Every
// failure is swallowed: auditing must never take down what it records.
func writeLine(session string, line []byte) {
dir := auditDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
file, err := os.OpenFile(filepath.Join(dir, session+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
defer file.Close()
_, _ = file.Write(line)
}

// flush blocks until every event queued before it has been written. It exists
// for tests and for a future graceful shutdown; the single background writer
// processes work in order, so the marker cannot pass earlier events.
func flush() {
startOnce.Do(startWriter)
done := make(chan struct{})
queue <- queued{done: done}
<-done
}

// auditDir is <dir-of-binary>/audit, mirroring how the SQLite database is placed
// next to the binary, unless OPENAGENT_AUDIT_DIR overrides it.
func auditDir() string {
if override := strings.TrimSpace(os.Getenv("OPENAGENT_AUDIT_DIR")); override != "" {
return override
}
exe, err := os.Executable()
if err != nil {
return "audit"
}
return filepath.Join(filepath.Dir(exe), "audit")
}

// sanitizeSession keeps a session id safe to use as a file name, and falls back
// to a fixed name when no session is known so events are never dropped.
func sanitizeSession(session string) string {
session = strings.TrimSpace(session)
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '-', r == '_', r == '.':
return r
default:
return '-'
}
}, session)
cleaned = strings.Trim(cleaned, "-.")
if cleaned == "" {
return "openagent"
}
return cleaned
}
93 changes: 93 additions & 0 deletions audit/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2025 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package audit

import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"testing"
)

func TestRecordAppendsJSONLPerSession(t *testing.T) {
dir := t.TempDir()
t.Setenv("OPENAGENT_AUDIT_DIR", dir)

Record(Event{Type: "tool_call", Tool: "read_file", SessionID: "sess1", ArgumentsLength: 12, Outcome: "success", DurationMs: 7})
Record(Event{Type: "tool_call", Tool: "search", Server: "web", SessionID: "sess1", Outcome: "failure"})
Record(Event{Type: ""}) // no type: must be dropped
flush()

events := readEvents(t, filepath.Join(dir, "sess1.jsonl"))
if len(events) != 2 {
t.Fatalf("got %d events, want 2 (empty-type event must be dropped)", len(events))
}
if events[0].Tool != "read_file" || events[0].Outcome != "success" || events[0].ArgumentsLength != 12 || events[0].DurationMs != 7 {
t.Errorf("first event fields wrong: %+v", events[0])
}
if events[0].Timestamp == "" {
t.Errorf("timestamp should be stamped by Record")
}
if events[1].Server != "web" || events[1].Outcome != "failure" {
t.Errorf("second event fields wrong: %+v", events[1])
}
}

func TestRecordFallsBackToDefaultSessionFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("OPENAGENT_AUDIT_DIR", dir)

Record(Event{Type: "tool_call", Tool: "time"})
flush()

if _, err := os.Stat(filepath.Join(dir, "openagent.jsonl")); err != nil {
t.Fatalf("event with no session id should land in openagent.jsonl: %v", err)
}
}

func TestSanitizeSession(t *testing.T) {
tests := map[string]string{
"": "openagent",
"sess_ABC-1.2": "sess_ABC-1.2",
"a/b\\c": "a-b-c",
"../../etc": "etc",
"..": "openagent",
}
for in, want := range tests {
if got := sanitizeSession(in); got != want {
t.Errorf("sanitizeSession(%q) = %q, want %q", in, got, want)
}
}
}

func readEvents(t *testing.T, path string) []Event {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
var events []Event
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
t.Fatalf("bad JSONL line %q: %v", scanner.Text(), err)
}
events = append(events, event)
}
return events
}
1 change: 1 addition & 0 deletions controllers/message_answer.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ func generateMessageAnswer(id string, responseWriter http.ResponseWriter, host s
McpToolSet: mcpToolSet,
ToolMessages: messages,
IsVision: model.IsVisionModel(modelProvider.SubType),
SessionID: chat.Name,
}
modelResult, err = model.QueryTextWithTools(modelProviderObj, question, writer, history, prompt, knowledge, toolSession, lang)
} else {
Expand Down
3 changes: 2 additions & 1 deletion controllers/openai_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ func (c *ApiController) chatCompletionsViaStore(store *object.Store, request ope
Messages: []*model.RawMessage{},
ToolCalls: nil,
},
IsVision: model.IsVisionModel(modelProviderRecord.SubType),
IsVision: model.IsVisionModel(modelProviderRecord.SubType),
SessionID: chat.Name,
}
modelResult, err = model.QueryTextWithTools(modelProviderObj, question, writer, history, prompt, []*model.RawMessage{}, toolSession, lang)
} else {
Expand Down
35 changes: 33 additions & 2 deletions model/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/ThinkInAIXYZ/go-mcp/protocol"
"github.com/openai/openai-go/v2/responses"
"github.com/sashabaranov/go-openai"
"github.com/the-open-agent/openagent/audit"
"github.com/the-open-agent/openagent/i18n"
"github.com/the-open-agent/openagent/mcp"
"github.com/the-open-agent/openagent/tool"
Expand All @@ -42,6 +43,10 @@ type ToolSession struct {
McpToolSet *mcp.ToolSet
ToolMessages *ToolMessages
IsVision bool
// SessionID identifies the chat/session these tool calls belong to. It is
// stamped onto every audit event so the audit log lands in one file per
// session; empty falls back to a shared file.
SessionID string
}

type ToolCallResponse struct {
Expand Down Expand Up @@ -206,7 +211,7 @@ func QueryTextWithTools(p ModelProvider, question string, writer io.Writer, hist

var toolFailed bool
var images []ImageAttachment
messages, images, toolFailed, err = callMcpTool(toolCall, serverName, toolName, toolSession.IsVision, toolSession.McpToolSet, messages, writer, lang)
messages, images, toolFailed, err = callMcpTool(toolCall, serverName, toolName, toolSession.SessionID, toolSession.IsVision, toolSession.McpToolSet, messages, writer, lang)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -286,11 +291,30 @@ func startHeartbeat(writer io.Writer, mu *sync.Mutex) chan<- struct{} {
return stop
}

func callMcpTool(toolCall openai.ToolCall, serverName, toolName string, isVision bool, mcpToolSet *mcp.ToolSet, messages []*RawMessage, writer io.Writer, lang string) ([]*RawMessage, []ImageAttachment, bool, error) {
func callMcpTool(toolCall openai.ToolCall, serverName, toolName, sessionID string, isVision bool, mcpToolSet *mcp.ToolSet, messages []*RawMessage, writer io.Writer, lang string) ([]*RawMessage, []ImageAttachment, bool, error) {
var arguments map[string]interface{}
ctx := tool.WithModelVision(context.Background(), isVision)

// One audit event is emitted for every exit path, including the early
// failures below - a malformed-argument call and a call to an unregistered
// tool are exactly what an audit log must not miss. The deferred Record runs
// whichever way the function returns; each path sets the final outcome.
start := time.Now()
auditEvent := audit.Event{
Type: "tool_call",
Tool: toolName,
Server: serverName,
SessionID: sessionID,
ArgumentsLength: len(toolCall.Function.Arguments),
Outcome: "attempted",
}
defer func() {
auditEvent.DurationMs = time.Since(start).Milliseconds()
audit.Record(auditEvent)
}()

if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {
auditEvent.Outcome = "failure"
return nil, nil, false, fmt.Errorf(i18n.Translate(lang, "model:failed to parse tool arguments: %v"), err)
}

Expand All @@ -315,13 +339,15 @@ func callMcpTool(toolCall openai.ToolCall, serverName, toolName string, isVision
if serverName == "" {
// builtin tools
if mcpToolSet.BuiltinTools == nil {
auditEvent.Outcome = "not_found"
return messages, nil, false, nil
}
result, err = mcpToolSet.BuiltinTools.ExecuteTool(ctx, toolName, arguments)
} else {
// MCP server tools
conn, ok := mcpToolSet.Connections[serverName]
if !ok {
auditEvent.Outcome = "not_found"
return messages, nil, false, nil
}
req := &protocol.CallToolRequest{
Expand Down Expand Up @@ -370,6 +396,11 @@ func callMcpTool(toolCall openai.ToolCall, serverName, toolName string, isVision
}
}

auditEvent.Outcome = "success"
if !response.Success {
auditEvent.Outcome = "failure"
}

responseJson, err := json.Marshal(response)
if err != nil {
return nil, nil, false, fmt.Errorf(i18n.Translate(lang, "model:failed to marshal tool response: %v"), err)
Expand Down
Loading