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
- AuditLogger service — subscribes to TerminalManager events
- Redactor — regex-based input sanitization
- Database tables — audit_events + audit_stream (Drizzle schema)
- Write batching — buffered inserts for high-volume output
- Config schema — extend config with audit section
- REST API — query endpoints for events and stream
- Retention cleanup — periodic purge of old entries
- Tests — audit logger unit tests, redaction tests, query integration tests
Acceptance Criteria
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:
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:
Configurable: Input logging
Records all input sent to the SSH backend. This is the "what did they type?" log.
Configurable: Output logging
Records all output received from the SSH backend. This is the "what did the server respond?" log. Significantly higher volume.
Configuration
Logging levels
Storage Design (extends #14)
Audit events table
For structured events (session lifecycle, auth, guardrails):
Terminal I/O stream table
For raw input/output recording — separate table because of volume:
Indexes:
Why two tables?
Integration Points
TerminalManager
The TerminalManager emits events that the audit logger subscribes to:
AuditLogger service
Subscribes to TerminalManager events, applies redaction, writes to DB:
Write batching
For high-volume output logging, batch writes to avoid per-chunk DB transactions:
Redaction
Input logging carries risk — passwords, tokens, and secrets flow through terminals. The redaction system applies regex patterns before persisting:
Default redaction patterns (applied even if user doesn't configure any):
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:
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:
Retention and Cleanup
Implementation Plan
Acceptance Criteria