Security fixes land on the latest release track (v2.x). Older releases are not maintained.
Please do not open a public issue for a security problem.
Use GitHub's private vulnerability reporting: open the Security tab of this repository and choose Report a vulnerability. That channel is visible only to the maintainers.
A useful report includes:
- the affected version, tag or commit;
- what the issue is and which component it affects (proxy, monitor, MCP, storage, recording format);
- reproduction steps or a minimal config;
- the impact as you understand it, and any suggested fix.
We aim to acknowledge a report within a few days, agree on a disclosure timeline with you, and credit you in the release notes unless you prefer otherwise.
Trajecta is local-first, but it handles real credentials and real conversation content, so a deployment still needs care:
- Cassettes store raw traffic. With the default
debug.mask_key: truethe recorder replaces theAuthorizationvalue withBearer fake-key-loggingand theapi-key/x-api-key/x-goog-api-keyvalues withfake-key-loggingin the recorded request headers before writing. Every other header, the request body, and the whole response are stored verbatim — that is what makes replay faithful. If you setdebug.mask_key: false, credentials are written to disk in plaintext. Either way, treattrace.output_dir/TRAJECTA_OUTPUT_DIRas sensitive and do not commit recordings of production traffic. - Bodies are verbatim unless secret masking is on.
debug.mask_keyrewrites four request headers in the cassette; it does not touch bodies.secret_masking.enabled(on by default, and turned off with an explicitfalse) goes further: a credential or a personal identifier found in a request body (currently a bearer credential, a complete PEM/SSH private-key block, the Chinese resident identity number, and the mainland mobile number; a bare eleven-digit run with no phone vocabulary around it is reported but not rewritten) is replaced by a same-length, format-preserving stand-in before the request reaches the provider, the stand-in is restored to the real value in the response, and the cassette still records the original body so replay and human inspection keep working. That last point is deliberate and it is the operator's risk to weigh: with masking on, the cassette on disk still contains the real credentials, exactly as it did before. What changes is what leaves the machine. The mapping from stand-in back to value is never written to the cassette. It is persisted by default in<output_dir>/secret_mask_mappings.db, an embedded bbolt file sealed with the same local key as the audit table (or plaintext when no key exists), so treat it exactly like the cassette directory: it holds the real values and it is the record that lets the next process recognise a stand-in from an earlier session instead of masking it again. Withsecret_masking.mapping_store.enabled: falsethe mapping stays process-local - seeded fromcrypto/randat startup - so a stand-in copied out of a trace cannot be reversed without that process, and a restart invalidates the old stand-ins. The rewrite itself is recorded twice: asecret.maskedprelude event in the cassette carries the rule and the offsets but no bytes, and onecredential_mask_auditsrow carries the real value and the stand-in that replaced it. That table is the per-trace place the pair is stored, and it is the operator's risk to weigh alongside the cassette - the values are sealed with the same local key as the channel credentials when that key exists, and stored in the clear when it does not, with each row'sstorage_moderecording which happened. It is readable throughGET /api/secret-masksand the MCP toollist_credential_masks, so treat it like the cassette directory rather than like a log, and read docs/SECRET_MASKING.md before relying on it: it lists what the engine covers and, more importantly, what it does not - truncated or mismatched private-key blocks (a complete block is rewritten; an incomplete one only reports its header), encoded or truncated forms, 15-digit legacy identity numbers, unallocated mobile segments, spaced or hyphenated phone numbers with no eleven-digit run, and operations inside provider-hosted tools that need the real value are all outside it. A private-key stand-in is base64-shaped but deliberately not a parseable key, so a flow that asks the model to parse or validate the key before the response reaches a local tool will fail. The identity-number stand-in keeps a valid province, birthdate and check character but deliberately not the real birthdate, so a flow that cross-checks the number against a separately entered date will disagree; the mobile-number stand-in keeps the allocated three-digit head but re-renders the eight subscriber digits, so a flow that shows or checks the last four digits will disagree too. - URLs and DSNs are redacted before they are stored or printed. A URL that reaches a cassette, the
logstable, a log line orconfig inspecthas the password in its userinfo replaced, every query parameter whose name matches the shared marker list (key,token,secret,password,passwd,credential,signature,sig,access_token,api_key,authorization,auth) replaced with a placeholder, and a userinfo username replaced when it matches that same list. Database DSNs are redacted in both shapes: the URL form covers the userinfo and the query string, and the keyword form covers everykey=valuefield. The residual is a userinfo username that names no secret, such as the literal API key inhttps://sk-live-abc@gateway.example.com/v1: the username is kept because it identifies the upstream, so do not configure an upstream that way — put the credential in the channel API key, which is encrypted.debug.mask_key: falsedoes not disable this redaction. - Channel credentials are encrypted at rest. API keys and secret headers are sealed with AES-256-GCM under a random 32-byte local key stored at
<output_dir>/trace_index.secret(mode0600). Back that file up together with the application database: without it the stored channel credentials cannot be decrypted. - The outbound proxy policy is stored in plaintext.
app_settings.network.proxyholds thehttp_proxy/https_proxy/no_proxyvalues as submitted, including auser:pass@userinfo when the proxy needs authentication. It is read and written only through the admin-onlyGET/PATCH /api/settings/network-proxy, and the password is redacted in the startup log, but it is not sealed with the local key the way channel credentials are. Do not put proxy credentials in a URL that is shared, and restrict the Monitor to trusted operators. - Change the default Compose credentials.
POSTGRES_PASSWORDin.env.exampleis an example value; the trackedconfig/config.yamlintentionally contains no provider keys. - Do not expose the monitor port to untrusted networks. The monitor, the proxy API and the MCP endpoint all authenticate (login session or a personal
Bearertoken), and the monitor is where provider credentials and recorded traffic are readable. Keep it on localhost or behind your own access control. - Personal tokens are hashed, not recoverable. Treat a leaked token as compromised and revoke it in the monitor; it cannot be looked up from the database.