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
43 changes: 29 additions & 14 deletions audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,33 @@ import (
"time"
)

const timeFormat = "2006-01-02T15:04:05.000Z07:00"
const (
timeFormat = "2006-01-02T15:04:05.000Z07:00"
maxArgumentsBytes = 64 * 1024
)

// 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"`
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"`
Arguments map[string]interface{} `json:"arguments,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.
// queueSize bounds how many events may be waiting to be written. 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
Expand All @@ -81,6 +84,15 @@ func Record(event Event) {
return
}
event.Timestamp = time.Now().UTC().Format(timeFormat)
event.Arguments = sanitizeToolInput(event.Tool, event.Arguments)
if encoded, err := json.Marshal(event.Arguments); err != nil {
return
} else if len(encoded) > maxArgumentsBytes {
event.Arguments = map[string]interface{}{
"truncated": true,
"originalBytes": len(encoded),
}
}

line, err := json.Marshal(event)
if err != nil {
Expand Down Expand Up @@ -117,11 +129,14 @@ func writeLine(session string, line []byte) {
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)
file, err := os.OpenFile(filepath.Join(dir, session+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer file.Close()
if err := file.Chmod(0o600); err != nil {
return
}
_, _ = file.Write(line)
}

Expand Down
125 changes: 125 additions & 0 deletions audit/sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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 (
"path"
"regexp"
"strings"
)

var (
credentialPattern = regexp.MustCompile(`(?i)\b(?:sk-(?:ant-|proj-)?[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9a-z_-]{30,}|xox[baprs]-[0-9a-z-]{12,}|eyJ[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\.[a-z0-9_-]{10,})\b`)
bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)[a-z0-9._~+/=-]{12,}`)
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [^-\n]*PRIVATE KEY-----.*?-----END [^-\n]*PRIVATE KEY-----`)
)

func sanitizeToolInput(toolName string, input map[string]interface{}) map[string]interface{} {
if input == nil {
return nil
}
sanitized := sanitizeMap(input)
if !isSensitiveWrite(toolName) || !hasSensitivePath(input) {
return sanitized
}
for key := range sanitized {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
switch normalized {
case "content", "oldstring", "newstring":
sanitized[key] = "[REDACTED: sensitive file content]"
}
}
return sanitized
}

func sanitizeMap(input map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(input))
for key, value := range input {
result[key] = sanitizeValue(key, value)
}
return result
}

func sanitizeValue(key string, value interface{}) interface{} {
if sensitiveKey(key) {
return "[REDACTED]"
}
switch typed := value.(type) {
case map[string]interface{}:
return sanitizeMap(typed)
case []interface{}:
result := make([]interface{}, len(typed))
for i, child := range typed {
result[i] = sanitizeValue("", child)
}
return result
case string:
return sanitizeString(typed)
default:
return value
}
}

func sensitiveKey(key string) bool {
normalized := strings.ToLower(key)
normalized = strings.NewReplacer("_", "", "-", "", ".", "").Replace(normalized)
if normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "idtoken" {
return true
}
for _, marker := range []string{"secret", "token", "password", "passwd", "credential", "privatekey", "apikey", "accesskey", "authorization", "cookie"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}

func sanitizeString(value string) string {
value = privateKeyPattern.ReplaceAllString(value, "[REDACTED PRIVATE KEY]")
value = bearerPattern.ReplaceAllString(value, "${1}[REDACTED]")
return credentialPattern.ReplaceAllString(value, "[REDACTED]")
}

func isSensitiveWrite(toolName string) bool {
normalized := strings.ToLower(toolName)
return normalized == "write" || normalized == "edit" || normalized == "write_file" || normalized == "edit_file" ||
strings.HasSuffix(normalized, "__write_file") || strings.HasSuffix(normalized, "__edit_file")
}

func hasSensitivePath(input map[string]interface{}) bool {
filePath, _ := input["file_path"].(string)
if filePath == "" {
filePath, _ = input["path"].(string)
}
return isSensitivePath(filePath)
}

func isSensitivePath(filePath string) bool {
if filePath == "" {
return false
}
normalized := strings.ToLower(strings.ReplaceAll(filePath, `\`, "/"))
base := path.Base(normalized)
if strings.HasPrefix(base, ".env") && base != ".env.example" && base != ".env.sample" && base != ".env.template" {
return true
}
if strings.HasPrefix(normalized, ".ssh/") || strings.Contains(normalized, "/.ssh/") || normalized == ".aws/credentials" || strings.HasSuffix(normalized, "/.aws/credentials") {
return true
}
if base == ".npmrc" || base == ".pypirc" || base == "credentials" || base == "id_rsa" || base == "id_ed25519" {
return true
}
return strings.HasSuffix(base, ".pem") || strings.HasSuffix(base, ".key")
}
1 change: 1 addition & 0 deletions model/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ func callMcpTool(toolCall openai.ToolCall, serverName, toolName, sessionID strin
auditEvent.Outcome = "failure"
return nil, nil, false, fmt.Errorf(i18n.Translate(lang, "model:failed to parse tool arguments: %v"), err)
}
auditEvent.Arguments = arguments

// Send tool-start event immediately so the frontend can show the tool call before execution
toolStartData := ToolCall{
Expand Down
Loading