audit

Append-only JSONL audit trail for routed tool calls: build_record, append_record, artifact_paths and resolve_audit_path, plus the redaction helpers that keep prompts, image payloads and credentials out of the trail.

See also

Insiders header, routing and audit for when a record is written, and Configuration for MCP_AUDIT_LOG_PATH.

Append-only JSONL audit trail for routed tool calls.

One record per line, one os.write per record. The file is opened with O_APPEND so concurrent writers append whole lines rather than overwrite one another: a crash mid-write can lose a line but never corrupt a line already written.

Nothing here may break a tool call. Every I/O or serialisation failure is logged and swallowed, because observability must never become a dependency of the business path.

Credentials and image payloads are never recorded. Prompts are reduced to their length plus a truncated SHA-256, image arguments are replaced by a marker, and any over-long string is digested instead of written verbatim, so a base64 blob cannot reach the trail even under an unexpected argument name.

novelai_image_mcp.audit.AUDIT_FILENAME = 'audit.jsonl'

File name used when no explicit audit path is configured.

novelai_image_mcp.audit.DIGEST_LENGTH = 16

Number of hex characters kept from a digest.

novelai_image_mcp.audit.MAX_ARGUMENT_LENGTH = 256

Longest argument string recorded verbatim; longer values are digested.

novelai_image_mcp.audit.MAX_RECORDED_HEADERS = 32

Largest number of headers kept in an audit record. The routing layer already caps its snapshot at 64; this is a second, tighter bound so a hostile client cannot grow an audit line without limit.

novelai_image_mcp.audit.MAX_RECORDED_HEADER_LENGTH = 256

Longest header value kept in an audit record.

novelai_image_mcp.audit.append_record(record, *, path)[source]

Append record as a single JSON line; return whether it was written.

The record is serialised first and then handed to exactly one os.write on a descriptor opened O_APPEND, so concurrent writers cannot interleave halves of a line. Failures are logged and reported as False – this function never raises, because a tool call must not fail on account of the audit trail.

Parameters:
Return type:

bool

novelai_image_mcp.audit.artifact_paths(result)[source]

Return the sorted *.png paths a tool result mentions.

Image tools report what they wrote as text (Saved 1 image(s): [...]); this reads those paths back for the audit record. The result is only observed, never rewritten, and a result without text blocks yields [].

Parameters:

result (Any)

Return type:

list[str]

novelai_image_mcp.audit.build_record(*, routing, tool, duration_ms, outcome, features=(), artifacts=(), arguments=None, unknown_keys=(), malformed=())[source]

Build one JSON-serialisable audit record.

features, artifacts, unknown_keys and malformed are sorted so two records describing the same state are byte-identical.

Parameters:
  • routing (RoutingContext)

  • tool (str)

  • duration_ms (float)

  • outcome (str)

  • features (Collection[str])

  • artifacts (Collection[str])

  • arguments (Mapping[str, Any] | None)

  • unknown_keys (Collection[str])

  • malformed (Collection[str])

Return type:

dict[str, Any]

novelai_image_mcp.audit.cap_headers(headers)[source]

Apply the audit-side cap to an already-sanitised header snapshot.

The input is expected to be RoutingContext.recorded_headers, which the routing layer has already stripped of every credential-bearing name. This only bounds the size: at most :data:MAX_RECORDED_HEADERS entries, each value at most :data:MAX_RECORDED_HEADER_LENGTH characters, kept in lexicographic order of the (lower-cased) name.

Parameters:

headers (Mapping[str, str] | None)

Return type:

dict[str, str]

novelai_image_mcp.audit.digest_text(value)[source]

Return the length and truncated SHA-256 of value, never the text.

Parameters:

value (str)

Return type:

dict[str, Any]

novelai_image_mcp.audit.resolve_audit_path(*, audit_log_path, output_dir=None)[source]

Resolve the audit destination.

An explicit audit_log_path wins; otherwise the trail lives beside the generated images at <output_dir>/audit.jsonl.

Parameters:
  • audit_log_path (str | None)

  • output_dir (str | Path | None)

Return type:

Path

novelai_image_mcp.audit.summarise_arguments(arguments)[source]

Summarise tool arguments so the record can never leak a payload.

Prompts become a length plus a truncated SHA-256, image arguments become a marker, and every other value is reduced to something small and JSON-serialisable.

Parameters:

arguments (Mapping[str, Any] | None)

Return type:

dict[str, Any]

When a record is written

The audit trail is part of the insiders surface: ToolCallLoggingMiddleware appends one record per tool call, and only while model-routing is active. A request that does not send the opt-in header writes nothing.

resolve_audit_path picks the destination — an explicit MCP_AUDIT_LOG_PATH wins, otherwise audit.jsonl inside NOVELAI_OUTPUT_DIR.

Record fields

build_record emits one flat, JSON-serialisable object per call:

Field

Content

ts

UTC timestamp, ISO-8601.

route

The sanitised route.

model / agent / client / session

The extracted routing value, or null.

tool

Tool name.

duration_ms

Wall-clock duration, rounded to three decimals.

outcome

ok, error or rejected.

features

Sorted names of the active insiders features.

artifacts

Sorted *.png paths the result mentioned.

unknown_keys

Sorted unrecognised keys from the insiders header.

malformed

Sorted unusable entries from the insiders header.

headers

Sanitised routing header snapshot, capped at MAX_RECORDED_HEADERS (32) entries of at most MAX_RECORDED_HEADER_LENGTH (256) characters, in lexicographic order of the lower-cased name.

arguments

Summarised call arguments — see below.

features, artifacts, unknown_keys and malformed are sorted so two records describing the same state are byte-identical.

Redaction

summarise_arguments reduces every argument before it can reach the file:

  • Prompt-shaped arguments (prompt, prompts, negative_prompt, negativeprompt) become {"length": n, "sha256": "<first 16 hex characters>"} via digest_text. The prompt text is never written.

  • Image-shaped arguments (image, images, init_image, mask, reference, references, base64) become the literal [redacted image].

  • Any string longer than MAX_ARGUMENT_LENGTH (256) characters is digested rather than written, so a base64 blob cannot slip through under an unexpected argument name.

  • Mappings are reduced to {"keys": n}; lists, tuples and sets to {"items": n}; any other object to its type name. None, booleans, ints and floats are recorded as they are.

Credential-bearing headers are removed upstream by routing._recorded, so the snapshot this module caps never contains authorization, cookie, set-cookie, proxy-authorization or any *-token / *-secret / *-password / *-key / *-credential header in the first place.

Concurrency and durability

append_record serialises the record first, then writes it with exactly one os.write on a descriptor opened with O_APPEND (mode 0o600, creating the parent directory when needed). Concurrent writers therefore append whole lines instead of interleaving halves of one: a crash mid-write can lose a line, but it cannot corrupt a line that was already written.

Failure behaviour

Observability never becomes a dependency of the business path. append_record catches its own OSError / TypeError / ValueError, logs the failure with logger.error, and returns False — it never raises, so there is no code path in which an unwritable audit file turns into a failed tool call.

See also