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
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.
logger, err := audit.NewLogger("/workspace/.openparallax/audit.jsonl")
if err != nil {
return fmt.Errorf("create audit logger: %w", err)
}
defer logger.Close()Entry
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).
| Field | Description |
|---|---|
EventType | The category of audit event (see Event Types below) |
ActionType | The action type string, e.g. "write_file", "run_command" |
SessionID | The session this event belongs to |
Details | A JSON string with event-specific metadata |
OTR | Whether this event occurred in an Off-The-Record session |
Source | Where the event originated, e.g. "pipeline", "shield" |
Log
func (l *Logger) Log(entry Entry) errorAppends an entry to the audit log. The method:
- Generates a unique ID for the entry
- Sets the timestamp to the current time
- Sets
previous_hashto the hash of the last entry - Canonicalizes the entry (deterministic JSON with sorted keys)
- Computes the SHA-256 hash
- Marshals the entry to JSON and appends it to the file
- Optionally indexes the entry in SQLite
Thread-safe. Concurrent calls are serialized through a mutex.
err := logger.Log(audit.Entry{
EventType: audit.ActionProposed,
ActionType: "run_command",
SessionID: "sess-123",
Details: `{"command":"ls -la","working_dir":"/workspace"}`,
Source: "pipeline",
})SetDB
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.
logger.SetDB(storageDB)The DBIndexer interface requires a single method:
type DBIndexer interface {
InsertLogEntry(entry *audit.LogEntry) error
}Close
func (l *Logger) Close() errorFlushes and closes the underlying file. After Close, no further Log calls should be made.
VerifyIntegrity
func VerifyIntegrity(path string) errorReads 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:
- Chain continuity: The entry's
previous_hashmatches the hash of the preceding entry - Entry integrity: The entry's
hashmatches the recomputed SHA-256 of its canonical form
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 JSONline N: chain broken: previous_hash "X" does not match expected "Y"-- the chain link is brokenline N: hash mismatch: stored "X", computed "Y"-- the entry content was modified
An empty or nonexistent file passes verification (no entries to verify).
ReadEntries
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).
type Query struct {
SessionID string
EventType audit.EventType
Limit int
}| Field | Description |
|---|---|
SessionID | Filter to entries matching this session ID. Empty means all sessions. |
EventType | Filter to entries matching this event type. Zero means all types. |
Limit | Maximum number of entries to return. Zero means no limit. |
// 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:
// 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.HashHash 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.
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):
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
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
)