mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-02 08:23:15 +00:00
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
|
|
/**
|
||
|
|
* Structured audit logging for sensitive tool calls.
|
||
|
|
*
|
||
|
|
* design.md §2.5 / §7.3 (Audit row): every `finance:*` (and any future
|
||
|
|
* `physical:*`) tool call must emit a structured audit record carrying the
|
||
|
|
* user `sub`, the tool name, a HASH of the args (never the raw args), the
|
||
|
|
* authorization decision, and the result. Secrets must never appear in the
|
||
|
|
* record — args are hashed precisely so raw values and tokens cannot leak into
|
||
|
|
* CloudWatch.
|
||
|
|
*
|
||
|
|
* The dispatcher (dispatch.ts) is the single emission point. An `AuditLogger`
|
||
|
|
* is injected so production writes structured JSON to stdout (→ CloudWatch in
|
||
|
|
* Lambda) while tests can assert against a capturing logger and never spam
|
||
|
|
* stdout.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { createHash } from 'node:crypto';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* One structured audit record. Shape is asserted by the audit test
|
||
|
|
* (design.md §7.3): no raw arg values, no secrets — only a hash.
|
||
|
|
*/
|
||
|
|
export interface AuditRecord {
|
||
|
|
/** The user's Google-federated identity (Cognito `sub`). */
|
||
|
|
sub: string;
|
||
|
|
/** The tool that was invoked. */
|
||
|
|
tool: string;
|
||
|
|
/** SHA-256 hash (hex) of the canonicalized raw input — never the raw args. */
|
||
|
|
argsHash: string;
|
||
|
|
/** The authorization decision that gated the call. */
|
||
|
|
decision: 'allow' | 'deny';
|
||
|
|
/** Whether the handler completed or threw. */
|
||
|
|
result: 'ok' | 'error';
|
||
|
|
/** ISO-8601 emission timestamp. */
|
||
|
|
ts: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sink for {@link AuditRecord}s. Injected into the dispatcher so the transport
|
||
|
|
* layer never logs directly.
|
||
|
|
*/
|
||
|
|
export interface AuditLogger {
|
||
|
|
log(record: AuditRecord): void;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Default logger: one structured JSON line per record to stdout. In Lambda this
|
||
|
|
* lands in CloudWatch Logs, satisfying design.md §2.5's audit requirement.
|
||
|
|
*/
|
||
|
|
export class ConsoleAuditLogger implements AuditLogger {
|
||
|
|
log(record: AuditRecord): void {
|
||
|
|
// A single JSON line keeps CloudWatch Insights queries simple.
|
||
|
|
console.log(JSON.stringify({ kind: 'audit', ...record }));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** No-op logger for tests that don't assert on audit output. */
|
||
|
|
export class NoopAuditLogger implements AuditLogger {
|
||
|
|
log(_record: AuditRecord): void {
|
||
|
|
// intentionally empty
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Capturing logger for tests: records are pushed to `records` so a test can
|
||
|
|
* assert the audit shape and that no raw arg value / secret appears.
|
||
|
|
*/
|
||
|
|
export class MemoryAuditLogger implements AuditLogger {
|
||
|
|
readonly records: AuditRecord[] = [];
|
||
|
|
log(record: AuditRecord): void {
|
||
|
|
this.records.push(record);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hash a tool's raw input into a stable hex digest for the audit record.
|
||
|
|
*
|
||
|
|
* The input is canonicalized (object keys sorted) before hashing so that two
|
||
|
|
* logically-equal inputs produce the same hash. The RAW value never appears in
|
||
|
|
* the output — this is what keeps secrets and PII out of the audit log.
|
||
|
|
*/
|
||
|
|
export function hashArgs(rawInput: unknown): string {
|
||
|
|
const canonical = canonicalize(rawInput);
|
||
|
|
return createHash('sha256').update(canonical).digest('hex');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Deterministic JSON serialization with sorted object keys. */
|
||
|
|
function canonicalize(value: unknown): string {
|
||
|
|
return JSON.stringify(sortKeys(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
function sortKeys(value: unknown): unknown {
|
||
|
|
if (Array.isArray(value)) return value.map(sortKeys);
|
||
|
|
if (value !== null && typeof value === 'object') {
|
||
|
|
const out: Record<string, unknown> = {};
|
||
|
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||
|
|
out[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|