Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ Supported local references include environment variables (`${NAME}` and `secretr

Use `miftah doctor` to inspect config and upstream readiness without printing process environment values.

## Audit logging

Set `audit.path` to record one terminal JSONL event for every supported MCP operation, including discovery, management, tool, resource, and prompt requests. Events include a per-process session ID, request/event ID, source and selected profiles, upstream, routing and policy metadata where applicable, terminal outcome, stable error code, and duration. Wrapper and upstream lifecycle transitions are recorded separately. Arguments are omitted unless `audit.includeArguments` is `true`.

New audit directories and files use owner-only permissions where the platform supports them. `audit.failureMode` defaults to `"fail-closed"`, which verifies the audit sink before dispatch and refuses the request if it cannot be prepared. A terminal write can still fail after an upstream side effect completes, so treat a post-dispatch `AUDIT_WRITE_FAILED` as an indeterminate outcome and do not blindly retry non-idempotent tools. Set it to `"fail-open"` only when availability outweighs that guarantee; the original operation remains available and `miftah_health` reports a redacted `AUDIT_WRITE_FAILED` audit-health entry.

## CLI

| Command | Purpose |
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The public server is built with the official `@modelcontextprotocol/sdk` `Server

The server advertises management tools plus tools discovered from the active profile. It advertises `tools.listChanged` and emits `notifications/tools/list_changed` after profile changes, restarts, and recovery that changes the public tool snapshot. Unknown names are rejected from the capability snapshot rather than guessed or forwarded. If a routing rule targets another profile, Miftah forwards only when that profile exposes the same name with an identical client-visible schema; otherwise it returns `TOOL_SCHEMA_MISMATCH`. In strict discovery mode, Miftah preflights every configured profile and rejects unavailable upstreams or any mismatched exposed tool contract before publishing a snapshot.

Every proxied tool call, resource read, and prompt retrieval enters `OperationPipeline`. It captures the source profile state before awaiting work, resolves routing against that fixed active-profile fallback, evaluates the selected profile policy, resolves the exact target upstream route, executes, redacts the result or error, and emits one terminal operation audit record when audit logging is configured. Tools retain their original upstream names for routing and policy compatibility; resource reads and prompt retrieval use the stable policy names `resources/read` and `prompts/get`. Denied, confirmation-required, blocked, and ambiguous operations never resolve or execute an upstream read/get route.
Every supported MCP request enters one outer audit scope. The scope records one terminal operation event on success or safe failure and also covers discovery/list failures, unknown names, and management tools that do not enter the proxy pipeline. `OperationPipeline` enriches proxied tool calls, resource reads, and prompt retrieval with captured source/target profile, upstream, routing, policy, and risk metadata; it does not emit its own record. It captures the source profile state before awaiting work, resolves routing against that fixed active-profile fallback, evaluates the selected profile policy, resolves the exact target upstream route, executes, and redacts the result or error. Tools retain their original upstream names for routing and policy compatibility; resource reads and prompt retrieval use the stable policy names `resources/read` and `prompts/get`. Denied, confirmation-required, blocked, and ambiguous operations never resolve or execute an upstream read/get route. Upstream managers publish typed lifecycle transitions, which the server records as separate audit events without letting audit I/O interrupt cleanup or recovery.

For a multi-entry `upstreams` map, `ResourcePromptRegistry` discovers resources and prompts from every configured upstream and publishes namespaced public values only after collision checks succeed. It names resources and prompts `<upstream>__<name>`, and exposes resources as `miftah://resource/<encoded-upstream>?uri=<encoded-redacted-upstream-uri>`. The registry retains the original upstream URI privately with the exact profile and upstream route. Prompt resource links and read-result sub-resources are registered as exact Miftah routes to their originating upstream. Before resource/prompt URI metadata crosses the boundary, structural redaction strips userinfo/fragments and redacts query values, including URI metadata returned by reads and prompt content. After the operation pipeline authorizes its selected profile, a read or prompt get resolves that exact route and forwards only to its originating upstream; unknown identifiers are rejected rather than forwarded. Aggregate pagination stores the individual upstream cursors behind opaque, bounded in-memory LRU state scoped to the profile and capability kind.

Expand All @@ -30,7 +30,7 @@ Configuration and runtime concerns are intentionally separate:
- `routing/` resolves explicit rules and safe fallbacks.
- `policy/` classifies proxied-operation risk and returns allow/deny/confirm decisions.
- `upstream/` owns child processes, MCP initialization, caching, health, and cleanup.
- `audit/` writes local JSONL metadata only.
- `audit/` owns request scopes, redacted local JSONL metadata, restrictive file permissions, serialized same-process writes, and audit health.
- `mcp/server/` adapts those services to MCP operations and management tools.

The design leaves transport and multi-upstream seams in the config and interfaces. Remote transports should be added as separate upstream session implementations rather than weakening the STDIO security defaults.
4 changes: 3 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ Routing rules receive a tool's original arguments unchanged. Resource reads expo

Policies classify these operation names as `read`, `write`, or `destructive` using configurable overrides and conservative name heuristics. `denyRisk` takes precedence over `allowRisk`; `requireConfirmation` returns a structured error instead of forwarding the operation.

Audit logging defaults to local JSONL when a path is configured. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing.
Audit logging writes local JSONL when a path is configured. Every supported MCP request emits one terminal operation event with a request ID, per-process session ID, source/selected profiles, stable outcome/error code, duration, and any available upstream, routing, policy, and risk metadata; wrapper and upstream lifecycle transitions emit separate event records. Arguments are excluded unless `includeArguments` is true, and all configured secret values are redacted before writing. Audit directories and files are created with owner-only permissions where the platform supports them.

`audit.failureMode` accepts `"fail-closed"` (the default) or `"fail-open"`. Fail-closed verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared; a terminal write error also surfaces as `AUDIT_WRITE_FAILED`. Because terminal writes occur after an upstream operation, that error can leave a non-idempotent operation's outcome indeterminate; do not blindly retry it. Fail-open preserves the request result and exposes a redacted `AUDIT_WRITE_FAILED` entry through `miftah_health`; it should be used only when availability is more important than complete auditability.

## Runtime-supported controls

Expand Down
5 changes: 4 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ Miftah is a credential broker, so safe defaults are part of the product contract
- upstream stderr, errors, diagnostics, audit entries, and tool results pass through redaction;
- profile switching can be disabled or locked to a single profile;
- destructive and ambiguous requests are not silently routed;
- audit records contain metadata, not sensitive payloads, by default;
- audit records contain metadata, not sensitive payloads or arguments, by default;
- audit files and directories are owner-only where platform support permits it, and audit-write failures are explicit;
- provider tokens should be separate, least-privilege tokens per account and risk level.

Audit writes default to fail-closed: Miftah verifies the configured sink before dispatch and refuses a request when the sink cannot be prepared. A terminal write can fail after an upstream side effect has completed, so a post-dispatch `AUDIT_WRITE_FAILED` has an indeterminate outcome and must not prompt a blind retry of a non-idempotent operation. An operator can set `audit.failureMode` to `"fail-open"` for availability-sensitive deployments; Miftah then preserves the request outcome but exposes a redacted `AUDIT_WRITE_FAILED` health entry. This mode trades complete auditability for availability.

Miftah cannot reduce privileges granted by a provider token. A read-only Miftah policy is a local blocklist, not a replacement for provider-side scopes. Avoid putting real credentials in examples, commits, or support logs.

The STDIO transport is the default because it avoids a network listener. Any future HTTP server must bind localhost by default and require explicit authentication before non-local binding.
3 changes: 2 additions & 1 deletion examples/github.miftah.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
"path": "~/.local/state/miftah/github.audit.jsonl",
"format": "jsonl",
"includeArguments": false,
"redact": true
"redact": true,
"failureMode": "fail-closed"
},
"tooling": {
"collisionStrategy": "prefix-upstream"
Expand Down
3 changes: 2 additions & 1 deletion examples/multi-upstream.miftah.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"path": "~/.local/state/miftah/dev-tools.audit.jsonl",
"format": "jsonl",
"includeArguments": false,
"redact": true
"redact": true,
"failureMode": "fail-closed"
},
"tooling": {
"collisionStrategy": "prefix-upstream"
Expand Down
129 changes: 114 additions & 15 deletions src/audit/audit-logger.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,128 @@
import { appendFile, mkdir } from "node:fs/promises";
import { chmod, mkdir, open, type FileHandle } from "node:fs/promises";
import { dirname } from "node:path";
import type { AuditEvent } from "./audit-types.js";
import { redactSecrets } from "../secrets/redact.js";
import type { AuditEvent, AuditFailureMode, AuditHealth } from "./audit-types.js";
import { SecretRedactor } from "../secrets/redact.js";
import { MiftahError } from "../utils/errors.js";

export interface AuditLoggerOptions {
secretValues?: readonly string[];
redactor?: SecretRedactor;
includeArguments?: boolean;
failureMode?: AuditFailureMode;
}

export class AuditLogger {
private readonly options: { secretValues: readonly string[]; includeArguments: boolean };
private static readonly writesByPath = new Map<string, Promise<void>>();
private readonly options: { includeArguments: boolean; failureMode: AuditFailureMode };
private readonly redactor: SecretRedactor;
private lastFailure?: AuditHealth["lastFailure"];

constructor(
private readonly path: string,
options: { secretValues?: readonly string[]; includeArguments?: boolean } = {}
) {
constructor(private readonly path: string, options: AuditLoggerOptions = {}) {
this.options = {
secretValues: options.secretValues ?? [],
includeArguments: options.includeArguments ?? false
includeArguments: options.includeArguments ?? false,
failureMode: options.failureMode ?? "fail-closed"
};
this.redactor = options.redactor ?? new SecretRedactor();
this.redactor.addAll(options.secretValues ?? []);
}

async log(event: AuditEvent): Promise<void> {
await mkdir(dirname(this.path), { recursive: true });
const safeEvent = redactSecrets(
const timestamp = new Date().toISOString();
const safeEvent = this.redactor.redactForAudit(
!this.options.includeArguments
? { ...event, arguments: undefined }
: event,
this.options.secretValues ?? []
: event
);
try {
await this.enqueue(() => this.writeLine(`${JSON.stringify({ timestamp, ...safeEvent })}\n`));
this.lastFailure = undefined;
} catch (error) {
const failure = this.recordFailure(error);
if (this.options.failureMode === "fail-closed") throw failure;
}
}
Comment thread
mohanagy marked this conversation as resolved.

/** Verifies that a fail-closed sink is writable before an operation can make a side effect. */
async ensureWritable(): Promise<void> {
if (this.options.failureMode !== "fail-closed") return;
try {
await this.enqueue(() => this.prepareFile());
} catch (error) {
throw this.recordFailure(error);
}
}
Comment thread
mohanagy marked this conversation as resolved.

health(): AuditHealth {
return this.lastFailure ? { state: "failed", lastFailure: structuredClone(this.lastFailure) } : { state: "healthy" };
}

private enqueue<Result>(operation: () => Promise<Result>): Promise<Result> {
const prior = AuditLogger.writesByPath.get(this.path) ?? Promise.resolve();
const write = prior.catch(() => undefined).then(operation);
const tail = write.then(
() => undefined,
() => undefined
);
await appendFile(this.path, `${JSON.stringify({ timestamp: new Date().toISOString(), ...safeEvent })}\n`, "utf8");
AuditLogger.writesByPath.set(this.path, tail);
void tail.then(() => {
if (AuditLogger.writesByPath.get(this.path) === tail) AuditLogger.writesByPath.delete(this.path);
});
return write;
}

private async writeLine(line: string): Promise<void> {
const file = await this.openAuditFile();
try {
await file.writeFile(line, "utf8");
} finally {
await file.close();
}
}

private async prepareFile(): Promise<void> {
const file = await this.openAuditFile();
await file.close();
}

private async openAuditFile(): Promise<FileHandle> {
const directory = dirname(this.path);
await mkdir(directory, { recursive: true, mode: 0o700 });
const file = await open(this.path, "a", 0o600);
try {
await setRestrictiveMode(this.path, 0o600);
return file;
} catch (error) {
await file.close();
throw error;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private recordFailure(error: unknown): MiftahError {
const failure = this.asWriteFailure(error);
this.lastFailure = {
timestamp: new Date().toISOString(),
errorCode: "AUDIT_WRITE_FAILED",
message: failure.message
};
return failure;
}

private asWriteFailure(error: unknown): MiftahError {
const message = this.redactor.redactText(error instanceof Error ? error.message : String(error));
return new MiftahError("AUDIT_WRITE_FAILED", `AUDIT_WRITE_FAILED: unable to write audit record: ${message}`);
}
}

async function setRestrictiveMode(path: string, mode: number): Promise<void> {
try {
await chmod(path, mode);
} catch (error) {
if (
!(error instanceof Error) ||
!("code" in error) ||
(error.code !== "ENOSYS" && error.code !== "ENOTSUP" && error.code !== "EOPNOTSUPP")
) {
throw error;
}
}
}
141 changes: 141 additions & 0 deletions src/audit/audit-trail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { randomUUID } from "node:crypto";
import { AuditLogger } from "./audit-logger.js";
import type { AuditEvent, AuditHealth, AuditRoutingSource, AuditStatus } from "./audit-types.js";

export interface AuditOperationInput {
operation: string;
name: string;
sourceProfile: string;
profile?: string;
arguments?: Record<string, unknown>;
}

export interface AuditScopeUpdate {
name?: string;
profile?: string;
upstream?: string;
routingReason?: string;
routingSource?: AuditRoutingSource;
policyName?: string;
policyDecision?: AuditEvent["policyDecision"];
risk?: AuditEvent["risk"];
}

export interface AuditScopeResult {
status: AuditStatus;
errorCode?: string;
}

export interface AuditLifecycleInput {
operation: string;
name: string;
profile: string;
upstream?: string;
lockToProfile?: string;
status: AuditStatus;
errorCode?: string;
}

/** Creates one final audit record per MCP request when audit logging is configured. */
export class AuditTrail {
readonly sessionId = randomUUID();

constructor(
private readonly wrapperName: string,
private readonly logger?: AuditLogger
) {}

beginOperation(input: AuditOperationInput): AuditScope {
return new AuditScope(this, input);
}

health(): { enabled: boolean; state?: AuditHealth["state"]; lastFailure?: AuditHealth["lastFailure"] } {
if (!this.logger) return { enabled: false };
return { enabled: true, ...this.logger.health() };
}

async ensureWritable(): Promise<void> {
await this.logger?.ensureWritable();
}

async write(event: AuditEvent): Promise<void> {
await this.logger?.log(event);
}

async writeLifecycle(input: AuditLifecycleInput): Promise<void> {
await this.write({
wrapper: this.wrapperName,
kind: "lifecycle",
eventId: randomUUID(),
sessionId: this.sessionId,
sourceProfile: input.profile,
profile: input.profile,
operation: input.operation,
name: input.name,
status: input.status,
durationMs: 0,
...(input.upstream === undefined ? {} : { upstream: input.upstream }),
...(input.lockToProfile === undefined ? {} : { lockToProfile: input.lockToProfile }),
...(input.errorCode === undefined ? {} : { errorCode: input.errorCode })
});
}

/** Records a background lifecycle event without letting audit I/O disrupt process management. */
recordLifecycle(input: AuditLifecycleInput): void {
void this.writeLifecycle(input).catch(() => undefined);
}

wrapper(): string {
return this.wrapperName;
}
}

/** Accumulates request context and prevents duplicate terminal records. */
export class AuditScope {
private readonly requestId = randomUUID();
private readonly startedAt = Date.now();
private readonly event: AuditOperationInput & AuditScopeUpdate;
private finalized = false;

constructor(
private readonly trail: AuditTrail,
input: AuditOperationInput
) {
this.event = { ...input, profile: input.profile ?? input.sourceProfile };
}

get isFinalized(): boolean {
return this.finalized;
}

update(update: AuditScopeUpdate): void {
Object.assign(this.event, update);
}

async finish(result: AuditScopeResult): Promise<void> {
if (this.finalized) throw new Error("Audit scope already has a terminal event");
this.finalized = true;
await this.trail.write({
wrapper: this.trail.wrapper(),
kind: "operation",
eventId: this.requestId,
requestId: this.requestId,
sessionId: this.trail.sessionId,
sourceProfile: this.event.sourceProfile,
profile: this.event.profile ?? this.event.sourceProfile,
operation: this.event.operation,
name: this.event.name,
status: result.status,
durationMs: Date.now() - this.startedAt,
...(this.event.upstream === undefined ? {} : { upstream: this.event.upstream }),
...(this.event.routingReason === undefined ? {} : { routingReason: this.event.routingReason }),
...(this.event.routingSource === undefined ? {} : { routingSource: this.event.routingSource }),
...(this.event.policyName === undefined ? {} : { policyName: this.event.policyName }),
...(this.event.policyDecision === undefined ? {} : { policyDecision: this.event.policyDecision }),
...(this.event.risk === undefined ? {} : { risk: this.event.risk }),
...(this.event.arguments === undefined ? {} : { arguments: this.event.arguments }),
...(result.errorCode === undefined ? {} : { errorCode: result.errorCode })
});
}
Comment thread
mohanagy marked this conversation as resolved.

}
Loading