Skip to content

Audit Go API

The Go audit package is the canonical implementation. All other language wrappers call into the compiled Go library.

Package: github.com/openparallax/openparallax/audit

NewLogger

go
func NewLogger(path string) (*Logger, error)

Creates an audit logger that appends to the JSONL file at path. If the file already exists, the logger reads the last entry to recover the chain hash. If the file does not exist, it is created. The parent directory is created if needed.

The returned Logger is safe for concurrent use from multiple goroutines.

go
logger, err := audit.NewLogger("/workspace/.openparallax/audit.jsonl")
if err != nil {
    return fmt.Errorf("create audit logger: %w", err)
}
defer logger.Close()

Entry

go
type Entry struct {
    EventType  audit.EventType
    ActionType string
    SessionID  string
    Details    string
    OTR        bool
    Source     string
}

Entry is the input type for logging. It contains the event metadata without the hash chain fields (those are computed automatically).

FieldDescription
EventTypeThe category of audit event (see Event Types below)
ActionTypeThe action type string, e.g. "write_file", "run_command"
SessionIDThe session this event belongs to
DetailsA JSON string with event-specific metadata
OTRWhether this event occurred in an Off-The-Record session
SourceWhere the event originated, e.g. "pipeline", "shield"

Log

go
func (l *Logger) Log(entry Entry) error

Appends an entry to the audit log. The method:

  1. Generates a unique ID for the entry
  2. Sets the timestamp to the current time
  3. Sets previous_hash to the hash of the last entry
  4. Canonicalizes the entry (deterministic JSON with sorted keys)
  5. Computes the SHA-256 hash
  6. Marshals the entry to JSON and appends it to the file
  7. Optionally indexes the entry in SQLite

Thread-safe. Concurrent calls are serialized through a mutex.

go
err := logger.Log(audit.Entry{
    EventType:  audit.ActionProposed,
    ActionType: "run_command",
    SessionID:  "sess-123",
    Details:    `{"command":"ls -la","working_dir":"/workspace"}`,
    Source:     "pipeline",
})

SetDB

go
func (l *Logger) SetDB(db DBIndexer)

Attaches a SQLite database for secondary indexing. When set, every Log call also inserts the entry into the database for fast queries. The JSONL file remains the primary record.

go
logger.SetDB(storageDB)

The DBIndexer interface requires a single method:

go
type DBIndexer interface {
    InsertLogEntry(entry *audit.LogEntry) error
}

Close

go
func (l *Logger) Close() error

Flushes and closes the underlying file. After Close, no further Log calls should be made.

VerifyIntegrity

go
func VerifyIntegrity(path string) error

Reads the entire audit log and verifies the hash chain. Returns nil if the chain is valid, or an error describing the first violation found.

Verification checks two things for every entry:

  1. Chain continuity: The entry's previous_hash matches the hash of the preceding entry
  2. Entry integrity: The entry's hash matches the recomputed SHA-256 of its canonical form
go
if err := audit.VerifyIntegrity("/workspace/.openparallax/audit.jsonl"); err != nil {
    log.Printf("Audit log tampered: %s", err)
    // Take remedial action
}

Error Messages

  • line N: invalid JSON: ... -- the entry on line N is not valid JSON
  • line N: chain broken: previous_hash "X" does not match expected "Y" -- the chain link is broken
  • line N: hash mismatch: stored "X", computed "Y" -- the entry content was modified

An empty or nonexistent file passes verification (no entries to verify).

ReadEntries

go
func ReadEntries(path string, q Query) ([]audit.LogEntry, error)

Reads audit entries from the JSONL file, applying optional filters. Returns entries in reverse chronological order (most recent first).

go
type Query struct {
    SessionID string
    EventType audit.EventType
    Limit     int
}
FieldDescription
SessionIDFilter to entries matching this session ID. Empty means all sessions.
EventTypeFilter to entries matching this event type. Zero means all types.
LimitMaximum number of entries to return. Zero means no limit.
go
// Get the 20 most recent entries across all sessions.
entries, err := audit.ReadEntries(path, audit.Query{Limit: 20})

// Get all blocked actions in a specific session.
blocked, err := audit.ReadEntries(path, audit.Query{
    SessionID: "sess-123",
    EventType: audit.ActionBlocked,
})

// Get the 5 most recent Shield errors.
errors, err := audit.ReadEntries(path, audit.Query{
    EventType: audit.ShieldError,
    Limit:     5,
})

Hash Chain Internals

Entry Lifecycle

When Log is called, the following happens internally:

go
// 1. Build the LogEntry with chain fields
auditEntry := audit.LogEntry{
    ID:           crypto.NewID(),        // UUID v4
    EventType:    entry.EventType,
    Timestamp:    time.Now().UnixMilli(),
    SessionID:    entry.SessionID,
    ActionType:   entry.ActionType,
    DetailsJSON:  entry.Details,
    PreviousHash: l.lastHash,            // chain link
    OTR:          entry.OTR,
    Source:       entry.Source,
}

// 2. Canonicalize (sorted keys at all levels)
canonical, _ := crypto.Canonicalize(auditEntry)

// 3. Hash
auditEntry.Hash = crypto.SHA256Hex(canonical)

// 4. Serialize and append
data, _ := json.Marshal(auditEntry)
fmt.Fprintf(l.file, "%s\n", data)

// 5. Update chain state
l.lastHash = auditEntry.Hash

Hash Computation

The hash is computed over the canonical JSON of the entry with the hash field set to its zero value (empty string). This means the hash covers all other fields including previous_hash, making the chain tamper-evident.

The canonicalization function (crypto.Canonicalize) produces deterministic JSON by sorting all map keys alphabetically at every nesting level. This is critical because Go's json.Marshal does not guarantee key ordering.

Chain Recovery on Restart

When NewLogger is called on an existing file, it reads the last line, parses the hash field, and uses it as lastHash. New entries chain from there seamlessly.

go
func readLastHash(path string) string {
    data, _ := os.ReadFile(path)
    lines := strings.Split(strings.TrimSpace(string(data)), "\n")
    if len(lines) == 0 {
        return ""
    }
    var entry audit.LogEntry
    json.Unmarshal([]byte(lines[len(lines)-1]), &entry)
    return entry.Hash
}

LogEntry Type

The full LogEntry struct (defined in types package):

go
type LogEntry struct {
    ID           string         `json:"id"`
    EventType    EventType `json:"event_type"`
    Timestamp    int64          `json:"timestamp"`
    SessionID    string         `json:"session_id,omitempty"`
    ActionType   string         `json:"action_type,omitempty"`
    DetailsJSON  string         `json:"details_json,omitempty"`
    PreviousHash string         `json:"previous_hash"`
    Hash         string         `json:"hash"`
    OTR          bool           `json:"otr"`
    Source       string         `json:"source,omitempty"`
}

Event Types

go
const (
    ActionProposed      EventType = 1
    ActionEvaluated     EventType = 2
    ActionApproved      EventType = 3
    ActionBlocked       EventType = 4
    ActionExecuted      EventType = 5
    ActionFailed        EventType = 6
    ShieldError         EventType = 7
    CanaryVerified      EventType = 8
    CanaryMissing       EventType = 9
    RateLimitHit        EventType = 10
    BudgetExhausted     EventType = 11
    SelfProtection      EventType = 12
    TransactionBegin    EventType = 13
    TransactionCommit   EventType = 14
    TransactionRollback EventType = 15
    IntegrityViolation  EventType = 16
    SessionStarted          EventType = 17
    SessionEnded            EventType = 18
    ConfigChanged           EventType = 19
    IFCClassified           EventType = 20
    ChronicleSnapshot       EventType = 21
    ChronicleSnapshotFailed EventType = 22
    SandboxCanaryResult     EventType = 23
)