Skip to content

Audit log: persistent recording of all terminal I/O through ShellWatch #16

Description

@rado0x54

Summary

Add a comprehensive audit log that records all input (and optionally output) flowing through ShellWatch. Every keystroke, command, and response that passes through the TerminalManager is persisted, attributable to a session, source, and (with #15) an authenticated identity.

Depends on: #14 (Persistence layer)

Motivation

ShellWatch sits between agents/users and SSH targets. This unique position means it can answer:

  • "What exactly did the AI agent type into the production server at 3am?"
  • "Did anyone run `rm -rf` on staging this week?"
  • "Show me the full terminal session for incident investigation"
  • "What commands triggered guardrail blocks?"

Without audit logging, ShellWatch is a passthrough. With it, it becomes a compliance and observability tool.

What Gets Logged

Always logged (session events)

These are lightweight metadata events, always on:

Event Data
`session.created` endpointId, source, apiKeyLabel
`session.closed` reason (user, idle timeout, guardrail, disconnect)
`session.error` error message
`auth.success` method, keyLabel, sourceIP
`auth.failure` method, reason, sourceIP
`guardrail.triggered` rule, action, matched input, message

Configurable: Input logging

Records all input sent to the SSH backend. This is the "what did they type?" log.

Event Data
`input.command` Full command string (from `shellwatch_exec`)
`input.raw` Raw input chunk (from WebSocket/SSH client keystrokes)
`input.keys` Named keys sent (from `shellwatch_send_keys`)

Configurable: Output logging

Records all output received from the SSH backend. This is the "what did the server respond?" log. Significantly higher volume.

Event Data
`output.data` Output chunk from SSH shell

Configuration

audit:
  enabled: true

  input:
    enabled: true
    # Log full command strings from shellwatch_exec
    logCommands: true
    # Log raw keystrokes from WebSocket/SSH input
    logRawInput: true
    # Redact patterns (applied before logging)
    redactPatterns:
      - pattern: "(password|passwd|secret|token)\\s*=\\s*\\S+"
        replacement: "$1=***REDACTED***"
      - pattern: "(?i)(bearer|authorization:)\\s+\\S+"
        replacement: "$1 ***REDACTED***"

  output:
    enabled: false    # off by default — high volume
    # When enabled, options:
    maxChunkSize: 4096    # truncate output chunks larger than this
    # Exclude output from specific endpoints (e.g., noisy dev boxes)
    excludeEndpoints: []

  # Retention policy
  retention:
    maxAgeDays: 90        # delete audit entries older than this
    maxSizeBytes: null    # optional: cap total audit log size

Logging levels

Level What's recorded Volume Use case
events-only Session lifecycle, auth, guardrails Low Always-on compliance
input + all commands and keystrokes Medium "What did they do?"
full + all SSH output High Full session reconstruction

Storage Design (extends #14)

Audit events table

For structured events (session lifecycle, auth, guardrails):

export const auditEvents = sqliteTable("audit_events", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  timestamp: text("timestamp").notNull(),
  sessionId: text("session_id"),
  endpointId: text("endpoint_id"),
  source: text("source"),              // ui, mcp, ssh
  apiKeyLabel: text("api_key_label"),   // who (from #15)
  eventType: text("event_type").notNull(),
  data: text("data"),                   // JSON
});

Terminal I/O stream table

For raw input/output recording — separate table because of volume:

export const auditStream = sqliteTable("audit_stream", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  timestamp: text("timestamp").notNull(),
  sessionId: text("session_id").notNull(),
  direction: text("direction").notNull(),  // 'input' | 'output'
  data: text("data").notNull(),
  offset: integer("offset").notNull(),     // byte offset in session stream
});

Indexes:

CREATE INDEX idx_events_session ON audit_events(session_id);
CREATE INDEX idx_events_timestamp ON audit_events(timestamp);
CREATE INDEX idx_events_type ON audit_events(event_type);
CREATE INDEX idx_stream_session ON audit_stream(session_id);
CREATE INDEX idx_stream_session_dir ON audit_stream(session_id, direction);

Why two tables?

  • audit_events — low volume, structured, always queried (dashboards, compliance)
  • audit_stream — high volume, append-only, rarely queried except during investigation. Can be purged independently. Could be moved to a separate database or file if size becomes an issue.

Integration Points

TerminalManager

The TerminalManager emits events that the audit logger subscribes to:

// In TerminalManager — emit on every input
sendInput(sessionId, input) {
  // ... guardrail check ...
  this.emit("audit:input", { sessionId, data: input });
  session.transport.write(input);
}

// Transport output handler
transport.on("data", (data) => {
  this.emit("audit:output", { sessionId, data });
  // ... existing output buffer logic ...
});

AuditLogger service

Subscribes to TerminalManager events, applies redaction, writes to DB:

class AuditLogger {
  constructor(
    private db: DrizzleDB,
    private config: AuditConfig,
    private redactor: Redactor,
  ) {}

  attach(terminalManager: TerminalManager) {
    terminalManager.on("audit:input", ({ sessionId, data }) => {
      if (!this.config.input.enabled) return;
      const redacted = this.redactor.apply(data);
      this.writeStream(sessionId, "input", redacted);
    });

    terminalManager.on("audit:output", ({ sessionId, data }) => {
      if (!this.config.output.enabled) return;
      this.writeStream(sessionId, "output", data);
    });

    terminalManager.on("status-change", (event) => {
      this.writeEvent(event.sessionId, `session.${event.status}`, event);
    });
  }
}

Write batching

For high-volume output logging, batch writes to avoid per-chunk DB transactions:

  • Buffer audit writes in memory (up to 100 entries or 1 second, whichever comes first)
  • Flush as a single transaction
  • Flush on session close (don't lose the tail)
  • Use SQLite WAL mode for concurrent read/write

Redaction

Input logging carries risk — passwords, tokens, and secrets flow through terminals. The redaction system applies regex patterns before persisting:

class Redactor {
  private patterns: Array<{ regex: RegExp; replacement: string }>;

  apply(input: string): string {
    let result = input;
    for (const { regex, replacement } of this.patterns) {
      result = result.replace(regex, replacement);
    }
    return result;
  }
}

Default redaction patterns (applied even if user doesn't configure any):

  • `password=...`, `passwd=...`, `secret=...` → redacted
  • `Authorization: Bearer ...` → redacted
  • `export._KEY=...`, `export._SECRET=...` → redacted

Redaction is best-effort — it won't catch everything (e.g., password entered at a prompt with no echo). But it significantly reduces the risk of secrets in the audit log.

Query API

REST endpoints for querying the audit log:

GET /api/audit/events?sessionId=...&eventType=...&from=...&to=...
GET /api/audit/events?endpointId=...&source=mcp&from=2025-01-01
GET /api/audit/stream?sessionId=...&direction=input
GET /api/audit/stream?sessionId=...  (full session replay data)

Future: Session replay in the web UI — play back a session's I/O stream like a recording.

Session Reconstruction

With full I/O logging enabled, a session can be reconstructed:

// Get the full input/output timeline for a session
const stream = await db.select()
  .from(auditStream)
  .where(eq(auditStream.sessionId, "sess_abc123"))
  .orderBy(auditStream.offset);

// Reconstruct what the terminal looked like
for (const entry of stream) {
  if (entry.direction === "input") console.log(`> ${entry.data}`);
  if (entry.direction === "output") console.log(entry.data);
}

Retention and Cleanup

// Run periodically (e.g., daily via a cron or on startup)
async function cleanupAuditLog(db: DrizzleDB, config: AuditConfig) {
  const cutoff = new Date(Date.now() - config.retention.maxAgeDays * 86400000);

  await db.delete(auditEvents)
    .where(lt(auditEvents.timestamp, cutoff.toISOString()));

  await db.delete(auditStream)
    .where(lt(auditStream.timestamp, cutoff.toISOString()));
}

Implementation Plan

  1. AuditLogger service — subscribes to TerminalManager events
  2. Redactor — regex-based input sanitization
  3. Database tables — audit_events + audit_stream (Drizzle schema)
  4. Write batching — buffered inserts for high-volume output
  5. Config schema — extend config with audit section
  6. REST API — query endpoints for events and stream
  7. Retention cleanup — periodic purge of old entries
  8. Tests — audit logger unit tests, redaction tests, query integration tests

Acceptance Criteria

  • Session lifecycle events are always logged when audit is enabled
  • Input logging captures commands and raw keystrokes (when enabled)
  • Output logging captures SSH responses (when enabled, off by default)
  • Redaction patterns strip secrets before persisting
  • Default redaction patterns are applied even without explicit config
  • Audit entries are queryable by session, endpoint, source, time range, event type
  • Full session I/O can be reconstructed from the audit stream
  • Write batching prevents performance degradation under high output volume
  • Retention policy automatically cleans up old entries
  • Audit log survives process restarts

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions