Security¶
Threat model, secrets management, and supply-chain considerations for Machina deployments.
Threat Model¶
Trust Boundaries¶
┌─────────────────────────────────────────────────────┐
│ Machina Process │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │
│ │ MCP │ │ Connector │ │ DocumentStore │ │
│ │ Transport│──│ Layer │──│ (RAG) │ │
│ └────┬─────┘ └─────┬─────┘ └────────┬─────────┘ │
└───────┼──────────────┼────────────────┼─────────────┘
│ │ │
MCP Client CMMS / IoT Ingested Documents
(trusted?) (trusted) (untrusted content)
Transport: stdio¶
Trust level: The MCP client is a local process on the same machine.
Risk: Any local process can connect. There is no authentication — stdio mode grants full CMMS write access to whoever invokes the process.
Single-user environments only
stdio mode is designed for local development and single-user IDE integrations (Claude Desktop, Cursor). Do not use it in multi-user or server environments. Use streamable-http with bearer token auth instead.
Transport: streamable-http¶
Trust level: The MCP client authenticates with a static bearer token.
Risk profile:
- Token compromise gives the holder every registered tool, writes included —
there are no per-token permissions. Rotate tokens immediately if leaked.
MACHINA_MCP_TOKENS_JSONmaps each token to a client ID, which is attached to the verified request; v0.4 does not yet write it to logs, traces or CMMS records, so it is not an audit trail on its own. - Network exposure: Bind to
127.0.0.1unless behind a reverse proxy with TLS. Machina does not terminate TLS itself.
Mitigations:
- Generate strong tokens:
openssl rand -hex 32. The server refuses to start with any token shorter than 32 characters. - Use
MACHINA_MCP_TOKENS_JSON(not the legacy comma-separated format), one token per client, so a single client can be revoked. - Keep
mcp.allowed_hosts/mcp.allowed_originsto the names clients really use (loopback by default): requests with anotherHostorOriginare rejected, which blocks DNS-rebinding attacks. - Place behind a reverse proxy (nginx, Caddy, Envoy) for TLS termination.
- Restrict network access via firewall rules to known MCP client IPs.
DocumentStore (RAG Ingestion)¶
Trust level: Ingested documents are untrusted content.
Risk: A malicious document could contain prompt-injection payloads that attempt to manipulate the LLM into unauthorized actions (e.g., "ignore previous instructions and create a work order for...").
Mitigations:
- Machina's prompt templates include injection-defense preambles.
- Sandbox mode (
sandbox: truein the config; the shipped deploy configs read it fromMACHINA_SANDBOX_MODE) prevents any write from executing — writes are logged but not sent to the CMMS. - Outside sandbox, the agent asks for confirmation before every write by
default (
confirmations: true). This is anAgentfeature: the MCP server has no confirmation step — the MCP client and its user decide which tool calls run — so keep an MCP server in sandbox mode until you trust that client. - Review ingested documents before adding them to the vector store in production.
Source-Path Sanitisation at the LLM Boundary¶
Risk: The raw DocumentChunk.source returned by a connector is typically an
absolute filesystem path (e.g. C:\Users\foo\bar\manual.md or
/var/data/manuals/pump.md). Passing it verbatim into the LLM-visible context
or the search_documents tool result exposes the host's filesystem layout —
any model asked to cite a source will repeat the path, sometimes prefixed with
the user account name. This is a deterministic leak rooted in the framework's
own payload, not an LLM hallucination.
Mitigations (always-on):
- The two runtime boundaries that build LLM-visible document payloads
(
Agent._gather_contextand thesearch_documentstool result) pass every source throughagent.prompts.safe_source, which strips directory components from path-like strings and passes through opaque IDs and URLs. format_document_resultsinvokessafe_sourceagain as defence in depth, so any future call site that forgets the sanitisation upstream is still protected at the prompt-formatting layer.-
Beyond the
sourcefield (v0.3.1): absolute paths can also be embedded in the chunk content text itself and in workflow error / output strings that flow back to the LLM.agent.prompts.safe_textscrubs those. It is applied to chunk content in both runtime boundaries, to tool errors and to theexecute_workflowsteperrorpayloads. It reduces each path that names a user or a file-share host to its file name:- user homes:
C:\Users\<user>\…andC:\Documents and Settings\<user>\…(backslashes or slashes,\\?\and//?/long-path prefixes,file:///C:/Users/…), WSL, Cygwin and Git Bash/mnt/<drive>/Users/<user>/…and/<drive>/Users/<user>/…,/home/<user>/…,/Users/<user>/…, the same below a macOS volume (/Volumes/<volume>/Users/<user>/…,/System/Volumes/Data/Users/<user>/…) or a mount point (/mnt/<disk>/Users/<user>/…), and/home/<user>/…below other folders of an absolute path (/srv/backup/home/<user>/…), also behindfile:///orfile://localhost/, the four-slash URI form of other schemes (sqlite:////home/<user>/…) or an option letter (-I/home/<user>/…); - shares:
\\host\share\…(WebDAV\\host@SSL\DavWWWRoot\…included, and a slash after the host,\\host/share/…),\\?\UNC\host\…,//host/share/…and//host\share\…, andfile:,smb:,cifs:,nfs:,sftp:,scp:,ssh:,afp:,dav:anddavs:URIs, user, password, port and bracketed IPv6 hosts included (smb://user:pass@host:445/…,smb://[fe80::1]/…,file:////host/share/…).
A path whose last component names an account becomes
<path>: a bare home directory, a share with nothing below it, or a folder right underUsers,home,homesorprofiles(\\PC01\C$\Users\<user>,\\NAS01\homes\<user>,/home/<user>/backup/home/<other>); below a profile or home, such a folder followed by a file name with an extension is an ordinary folder (/home/<user>/proj/users/models.py). Escaped text is matched too: the doubled backslashes ofrepr()and JSON ('C:\\Users\\<user>\\…'), and text escaped twice. Paths joined without whitespace (a.pdf;C:\Users\…,PATHlists) are found one by one, and a closing bracket the path did not open stays in the prose ((C:\Users\<user>)becomes(<path>)). In quotes (repr(), JSON,str(OSError), Markdown code) the user or share name, aOneDrive - …folder and the file name may contain spaces, and another folder may hold words as in unquoted paths; a quoted command or sentence that only starts with a path and a lowercase word keeps its words. Unquoted Windows paths and shares accept a user name of up to four words (Maria de la Cruz), also right under a share's accounts folder (\\PC01\C$\Users\john smith\…,smb://nas01/homes/john smith/…), a deeper folder of up to four words that starts with a capitalised word (New folder,Documenti tecnici) or whose later words are capitalised or digits (x64 Release), and eight words afterOneDrive -, each when a separator follows.Deliberately preserved, so manuals and messages keep full fidelity: instructional system paths (
/etc,/usr/bin,C:\Program Files,C:\Windows\System32), relative and menu paths (Settings/Users/Roles), REST paths (/api/users/42,/api/Users/42),\\.\pipe\…device paths, web URLs (http(s),s3,gs,ftp(s), kept whole), service URIs, also on localhost (mqtt://broker/topic,mqtt://localhost/home/…,opc.tcp://plc:4840/…), and relative database URLs (sqlite:///home/data.db). Known limits:- an unquoted profile path keeps the later words of a user name with more
than four words, and of a bare spaced name at the end of a sentence
(
C:\Users\Mario Rossi); - prose within four words after a user name or a capitalised folder, or a
capitalised word or digit before a later slash or backslash, can be
taken for a folder (
C:\Users\me\x.csv Line 2\P-202keeps onlyP-202); - a POSIX path below a folder whose name holds a space
(
/srv/my backup/home/<user>) is not matched, nor is/Users/<user>below folders other than those listed above (/srv/backup/Users/<user>) or a Linux removable-media mount (/media/<user>/…,/run/media/<user>/…); - a relative path into a home (
../../home/<user>/…) is kept, like any relative path; - Windows profiles below an NT device or volume path
(
\Device\HarddiskVolume3\Users\<user>,\\?\Volume{…}\Users\<user>) or a drive folder (E:\Backup\Users\<user>), JSON with escaped slashes ("\/home\/<user>\/…") and a user name holding a non-breaking space are not matched; - an absolute path with a
/home/segment is treated as a home (/api/home/dashboard), and a lowercase/users/<user>/(a case-insensitive macOS volume) is not matched; - a web URL whose own path names a user (
http://intranet/home/<user>/…) is kept, and a protocol-relative URL (//cdn.example.com/lib.js) reads as a share; - a bare
\\hostwith no share is not matched, because it cannot be told apart from escaped regex text such as\\d+. -
Workflow outputs: an
agent.reasonstep's prompt goes to the model provider, and so does theoutput_summarythe agent'sexecute_workflowtool returns for each step. Both are rendered byagent.prompts.render_for_llm: -
Document chunks from a
docs.search_documentsstep become aSource:line with the file name (safe_source), page and section, then the text (safe_text). Chunks nested in dicts, lists and tuples are rendered the same way. - A value read from a
sourcefield ({find_doc.source},{trigger.source}), and a quotedsourcevalue in a rendered dict, dataclass repr or JSON string, keeps only its file name when it is an absolute path or a file-share URI. Any other source, such as an alarm'smqtt://origin, an OPC-UA node id or a relative path, keeps its text. - Any other value passes through
safe_text. A value that starts with a home or share path is scanned as one quoted value, so a value that is exactly a home directory or share becomes<path>whatever words the user name holds; a value that starts with a bare home directory and continues with prose (/home/<user> has 2 GB free) also becomes<path>. - Not yet covered: the MCP server's tool results (
machina_search_manualschunk content,{"error": str(exc)}results) do not yet pass throughsafe_text; only thesourcefield is sanitised there. - The system prompt's Guideline 8 explicitly forbids the LLM from disclosing absolute file paths, directory structures, database schemas, or system architecture. This is a backstop, not the primary defence — the primary defence is to never hand those values to the LLM in the first place.
- The raw
chunk.sourceremains available for non-LLM consumers (logs, trace files protected separately byActionTracer.redacting_dump_json). See Traces / Redaction for the parallel mechanism on the trace export side.
- user homes:
Sandbox Enforcement (Layer-Wide)¶
Trust level: Sandbox mode is the safety boundary for evaluation, demos, and initial deployment — no real external side effect must execute while it is on.
Guarantee (v0.3.1): sandbox enforcement is a layer-wide invariant, not a
per-connector choice. Every external-mutation path is guarded by the
@sandbox_aware decorator, which raises SandboxViolationError before the
method body runs:
- CMMS
create_work_order/update_work_order(Generic, SAP PM, Maximo, UpKeep, Excel/CSV, SQL) - Comms
send_message(Telegram, Slack, Email) - MQTT
publish - Calendar
create_event/delete_event
CliChannel.send_message is intentionally exempt — it only prints to
stdout and must keep working in sandbox so the agent can still reply.
MCP request tasks: each MCP tool call runs in its own asyncio task that does
not inherit the _sandbox_mode contextvar set once at server startup. Both
the domain (mcp.tools._runtime) and vendor (mcp.tools_vendor._runtime)
helpers re-establish it on every request, and the vendor tools read
get_sandbox_mode() only after that — otherwise a raw vendor write (e.g. the
Maximo attribute update) would get past the tool's own check in sandbox mode.
Each vendor tool also writes through a connector helper with its own
@sandbox_aware guard (SAP PM's _write_with_csrf, Maximo's
_patch_resource, which update_work_order also uses), so that check is not
the only gate.
Companion invariant — write integrity: the write paths also guard
against duplicates. Auto-generated work-order IDs are a deterministic content
hash (auto_work_order_id); the Excel/CSV, SQL and local Generic CMMS
connectors return the existing work order when its ID is already there, and
the Generic CMMS REST mode sends the ID to the backend. The agent loop
memoises side-effecting tools per turn. The vendor connectors' HTTP retries
are method-aware: POST and PATCH are not retried on network errors,
timeouts or 503 answers, since any of those can follow a write the server
already made; 429 answers are retried for every method because the server
refused the request without processing it. SAP PM, Maximo and UpKeep let the
CMMS number new work orders, so these guards do not deduplicate a create
retried later against them (see Uptime).
Trace JSONL Files¶
Trust level: Internal diagnostic data.
Risk: Trace files (written only when you attach a JSONLExporter) may
contain:
- The first 200 characters of each tool result and workflow step output (asset names, work-order descriptions, …)
- Asset IDs, connector names and LLM token/cost data
They do not contain prompts or message text, and metadata values under
secret-like keys (token, password, secret, api_key, client_secret,
authorization) are redacted on export.
Mitigations:
- The exporter creates its directory with mode
0700and files with0600(POSIX); keep them there. - Review what tool results reveal before shipping trace files to external systems.
- Rotate and archive traces periodically (the exporter starts a new file each UTC day).
Log Output¶
Trust level: Internal diagnostic data.
Risk: a traceback rendered with each frame's local variables exposes
whatever those frames held, and a REST connector's frames hold its auth
headers. structlog's console renderer prints frame locals by default when
rich is installed (the litellm and docs-rag extras pull it in), and
structlog.processors.dict_tracebacks includes them in JSON output by
default.
What Machina logs:
- After
configure_logging(), every traceback that reaches its handler, from Machina, your application or a third-party library, is rendered as plain text in anexceptionfield, in console and JSON output. The text has the exception type, message and chained causes, and each frame's file, line and function, but no frame variables. - Machina's own exception logs write that text themselves
(
exception=traceback_text(exc)), so they carry no frame variables under any structlog configuration, including none at all. - Exception messages are logged verbatim, and the traceback shows each frame's source line.
- When
configure_logging()runs, it raises thehttpxandhttpcoreloggers to WARNING. A stricter level the application already set (ERROR, say) is kept, never lowered. Two kinds of line are therefore not logged: httpx's per-request INFO lines, which carry each request's full URL (the CMMS host and the record IDs and filter values of the read: UpKeep part IDs, SKUs in SAP PM's$filterand Maximo'soslc.where), and httpcore's DEBUG lines, which carry the host and port and each response's headers. Limitations: - The levels are set once, when
configure_logging()runs. An application that does not call it, calls it and then lowers the two loggers, or reconfigures logging afterwards (logging.config.dictConfig, its own handlers on those loggers) gets those lines in its logs again. Logging systems configured independently ofconfigure_logging()are not protected by it. - Only the
httpxandhttpcoreloggers are covered. Other libraries' records reach the log as those libraries write them, without Machina's redaction: for example urllib3 and the Google API client at DEBUG. - Machina's own retry warnings (
http_rate_limit_retry,http_network_retry,http_retry_after_exceeds_max_backoff) log the request URL. They name UpKeep's listed-part requests as/api/v2/parts/<listed part>. Other URLs are logged as sent: a record path with its ID, or a Maximo or SAP PM next-page link with its query. First-page filters, sent as query parameters, are not in that URL. - The Telegram bot token is kept out of log output:
- The Bot API carries the token in every URL. httpx logs request URLs at INFO, and python-telegram-bot logs its API URLs at DEBUG.
TelegramConnectorreplaces its token with<redacted>in the records of those loggers. The redaction is in place fromconnect()until the process ends. - A token the server rejects surfaces as
ConnectorAuthError("Telegram rejected the bot token"), without python-telegram-bot's message, which contains the token. - A startup error whose message names the token is withheld the same way.
- Not covered: a host renderer that prints frame locals on a python-telegram-bot frame, since
ExtBot's repr includes the token. Useconfigure_logging(). - Other credentials that a library puts into an exception message or a request URL are not scrubbed. Keep them out of URLs, and treat log output as sensitive.
Mitigations:
- Call
configure_logging()at startup. An application that configures structlog itself, or logs exceptions without callingconfigure_logging(), gets structlog's defaults for its ownexc_infologs, and those print frame locals. GiveConsoleRendererexception_formatter=structlog.dev.plain_traceback, and putstructlog.processors.format_exc_info, notdict_tracebacks, before a JSON renderer. - A logging handler you add after
configure_logging()formats third-party records itself: give itformat_exc_infotoo.
Secrets Management¶
Baseline: Environment Variables¶
The simplest approach — secrets live in an environment file, and the YAML
config references them as ${VAR} placeholders (a missing variable fails the
start instead of falling back):
# /etc/machina/machina.env (systemd)
# chmod 600, owned by root
MACHINA_MCP_TOKENS_JSON={"<64-hex token>": "client-a", "<64-hex token>": "client-b"}
MACHINA_CMMS_API_KEY=...
For Docker, use .env with docker compose (deploy/docker/.env, never
committed). The MCP server needs no LLM key; an agent you run needs its
provider's key (OPENAI_API_KEY, ANTHROPIC_API_KEY, …), read by LiteLLM.
Advanced: External Secret Stores¶
For production deployments with stricter compliance requirements:
HashiCorp Vault / Azure Key Vault: Wrap the Machina start command in a script that fetches secrets and injects them as environment variables:
#!/bin/bash
export MACHINA_MCP_TOKENS_JSON=$(vault kv get -field=tokens secret/machina/mcp)
export MACHINA_CMMS_API_KEY=$(vault kv get -field=api_key secret/machina/cmms)
exec /opt/machina-venv/bin/machina mcp serve \
--transport streamable-http \
--host 127.0.0.1 \
--config /etc/machina/config.yaml
Update the systemd unit's ExecStart to point to this wrapper script.
SOPS for GitOps:
Encrypt your .env or config.yaml with SOPS
and decrypt at deploy time:
What to Protect¶
| Secret | Where used | Rotation impact |
|---|---|---|
MACHINA_MCP_TOKENS_JSON |
MCP client auth | Restart required; coordinate with MCP clients |
CMMS credentials (e.g. MACHINA_CMMS_API_KEY) |
CMMS connector, via ${VAR} in the config |
Restart required |
OPENAI_API_KEY / ANTHROPIC_API_KEY |
LLM calls of agents you run (not the MCP server) | Restart required |
| OPC-UA certificates | IoT connector | Restart required; re-establish subscriptions |
Supply Chain¶
Dependencies¶
Machina's Python dependencies are declared in pyproject.toml with version constraints.
Review the dependency tree before deploying:
Container Image¶
The Docker image (deploy/docker/Dockerfile) uses a multi-stage build with
python:3.11-slim-bookworm as the base. To pin the base image digest:
Vulnerability Scanning¶
# Scan the Python dependencies installed in the Machina environment
/opt/machina-venv/bin/python -m pip install pip-audit
/opt/machina-venv/bin/python -m pip_audit
# Scan container image
docker scout cves machina:latest
# or
trivy image machina:latest
Hardening Checklist¶
- [ ] Use streamable-http transport (not stdio) in multi-user environments
- [ ] Generate unique MCP tokens per client with
openssl rand -hex 32 - [ ] Map tokens to client identities in
MACHINA_MCP_TOKENS_JSON - [ ] Place behind a TLS-terminating reverse proxy, and list its host name in
mcp.allowed_hosts - [ ] Restrict
/etc/machina/machina.envtochmod 600 - [ ] Enable sandbox mode during initial deployment (
sandbox: true, orMACHINA_SANDBOX_MODE=truewith the shipped configs) - [ ] Keep exported trace files in the exporter's
0700directory - [ ] Call
configure_logging(), or configure structlog with a traceback formatter that omits frame locals (Log Output) - [ ] Review documents before ingesting into DocumentStore
- [ ] Run
pip-auditor equivalent in CI - [ ] Pin the Docker base image digest in production builds