An independent audit of this fork found the headline claim -- 'the API key never
reaches a log line' -- was broken on a path I had not covered.
formatAPIErrorWithContext serialized the request params into the error string, and
for auth.login_ex those params ARE the plaintext key. Not debug-gated, not
redacted. Any middleware error frame on authentication (bad key, or a reconnect
that re-authenticates and fails mid-session) put the key into log.Fatalf at startup
AND, through CallTool's error return, into the model's context and the transcript.
My redaction test only covered the debug request-log frame, which is precisely why
this survived.
Worse, the deprecated auth.login_with_api_key passes the key as a bare positional
param -- a naked string with no key name -- so key-based redaction was structurally
incapable of masking it. redactParamsForError now masks every scalar param of any
auth.* method outright, leaving maps to key-based redaction so username/mechanism
still show up in the error.
Redaction gaps also closed: env vars come back as [{name:DB_PASSWORD,value:
...}], where the secret sits under the generic key value and no key rule could
see it; bindpw/keytab/bare-key were absent from the hints (upstream's
maskCredentials only masks those at the top level, so a nested one bypassed both);
and the float64 round-trip silently corrupted 64-bit integers like ZFS guids.
-insecure was a no-op: verification was always off while the flag claimed to be
what turned it off. That undercuts the ws:// rejection entirely -- refusing
plaintext to protect the key is hollow if the wss:// connection trusts any cert.
Verification is now on by default; -insecure genuinely disables it and warns.
TrueNAS is self-signed, so -insecure is required against a stock box -- but as an
explicit choice, not a silent default.
Remaining limit is documented, not hidden: redaction is key-name-based, so a secret
inside an opaque string blob (a custom app's compose YAML) is not caught. A test
pins that behaviour so it can't be mistaken for safety.
Read-only gate audited clean: gate before dispatch, fail-closed for unknown tools,
no mutating middleware method reachable from an allowlisted tool.
6.2 KiB
Changelog
All notable changes to this fork are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Version numbers are this fork's own. Upstream (truenas/truenas-mcp)
last tagged v0.0.4 in February 2026; its release workflow was removed in July 2026, so builds from
source are the only supported path.
[Unreleased]
Fixed (from an independent audit of the fork)
- HIGH — the API key leaked through the API-error formatter.
formatAPIErrorWithContextserialized the request's params into the error string, and forauth.login_exthose params are the plaintext key. That path was neither-debug-gated nor redacted, so a middleware error frame on authentication (bad key, or a mid-session reconnect that re-authenticates and fails) put the key into two sinks:log.Fatalfat startup, and — viaCallTool's error return — the model's context and the transcript. The earlier redaction only covered the debug request-log frame, which is exactly why this survived. Params and the middleware-suppliedTracenow both go throughredactForLog. - HIGH — the deprecated auth call leaked a bare positional key.
auth.login_with_api_keypasses the key as a naked string param with no key name, so key-based redaction could not see it at all.redactParamsForErrornow masks every scalar param of anyauth.*method outright, while leaving maps to key-based redaction so useful context (username, mechanism) survives. - MEDIUM — redaction was blind to secrets under generic keys. TrueNAS returns env vars as
[{"name":"DB_PASSWORD","value":"hunter2"}]; the secret sits undervalue, which matches no key hint. Redaction now detects the name/value pair shape and masks the value when the name looks like a credential. Non-secret env vars keep their values. - MEDIUM —
bindpw,keytab, and a barekeywere not masked. Upstream'smaskCredentialsonly handles these at the top level, so a nested directory-services credential bypassed both it and redaction. Added as hints; barekeyis an exact-match rule sokeyboard/monkeyaren't shredded. -insecurewas a no-op.InsecureSkipVerifywas set unconditionally and the flag only printed a log line — certificate verification was always off while the flag advertised itself as the thing that disabled it. That silently undercut the reason we hard-rejectws://: refusing plaintext to protect the key means little if thewss://connection then trusts any certificate, since an active MITM can present one and capture the key duringauth.login_ex. Verification is now ON by default and-insecuregenuinely disables it, with a warning. TrueNAS ships a self-signed cert, so-insecureis required against a stock box — but it is now an explicit choice, not a silent default.- Large integers were corrupted by redaction.
RedactJSONunmarshalled intointerface{}, decoding every number asfloat64and silently mangling 64-bit values (a ZFS guid15032414960031428871came back as...429000). Now decodes withUseNumber(). - Bracketed IPv6 without a port produced a double-bracketed URL (
wss://[[fd00::1]]:443/...).
Known limit (documented, not fixed)
Redaction masks by key name (plus the name/value pair shape). A secret embedded inside an opaque
string blob — e.g. a custom app's docker-compose YAML returned as one field, with
POSTGRES_PASSWORD: hunter2 inside it — is not caught, because the key holding the blob isn't
credential-shaped and we will not regex the interior of arbitrary strings. get_app_config on a
custom/compose app is therefore not fully safe; prefer query_apps for those. A test asserts this
current behaviour so it can't be mistaken for safety.
[1.0.0] - 2026-07-12
Forked from upstream at 9acb432.
Added
-read-onlyflag. A fail-closed allowlist: the server serves 31 non-mutating tools and refuses the remaining 21. Tools that are not explicitly allowlisted — including any that upstream adds in future — are refused rather than served. Refused tools are omitted fromtools/listand rejected at dispatch, so a client that names one directly still gets an error.- Unconditional secret redaction. Every tool response is parsed and credential-shaped fields
(
password,token,secret,encryption_key,access_key,api_key, …) are masked before the response leaves the server. Applies in read-write mode as well. Responses that are not JSON pass through untouched. - Tests (
tools/readonly_test.go) asserting that all 21 mutating tools are both hidden and refused, that an unknown tool name fails closed, that read-write mode is unaffected, and that a realisticapp.configpayload is redacted without destroying non-secret fields. The mutating-tool list is asserted against the live registry, so an upstream rename fails the build rather than silently widening the boundary.
Fixed
- README inaccuracy. Upstream states "Dry-Run Mode — Preview changes before execution for all
write operations". Dry-run is opt-in per call;
ExecuteWithDryRun()falls through to real execution when the argument is omitted, andsystem_rebootregisters an empty input schema so it accepts no arguments at all. The documentation now describes the actual behaviour and points to-read-onlyfor a real boundary.
Known issues (inherited from upstream, not yet addressed)
auth.login_with_api_keyis deprecated in TrueNAS 26 and slated for removal in 27; the replacement isauth.login_ex. Works on Fangtooth; will break on a later upgrade.- The client speaks the legacy DDP-style protocol over the unversioned
/websocketendpoint. The modern endpoint since 25.04 is/api/current, which speaks pure JSON-RPC 2.0 and ignores this handshake (connecting there hangs — verified live on 25.10.4)./websocketstill works on 25.10 and through Fangtooth, so this is not urgent, but migrating to/api/currentis a protocol rewrite of the connect/method framing, not a URL change. The port, at least, is no longer hardcoded (see Fixed).