Summary
POST /api/credentials/update (docker/base-image/agent_server/routers/credentials.py:74) has no callers anywhere — not the backend, not the MCP server, not startup.sh, not the frontend, not the enterprise submodule. Its own docstring says otherwise:
"This endpoint is called by the Trinity backend when credentials are updated."
Two other places treat it as live:
So the documented escape hatch for configuring MCP servers does not exist, and it is the only implementation of the .mcp.json.template contract the agent guide promises (#2007).
Verification
The backend's agent-facing credential calls are /api/credentials/status (routers/credentials.py:72), /inject (:345), /read + /list (credential_encryption.py:308,329,346), /reload-token (subscription_auto_switch.py:361). startup.sh:541 calls only /api/internal/decrypt-and-inject. Every agent URL in the backend is a literal f-string with a fixed path; no dynamic construction reaches it. Searched src/, docker/, scripts/, config/, the .claude submodule and the populated src/backend/enterprise submodule.
Why it matters if anyone re-wires it
The renderer is a whole-text str.replace over .mcp.json.template, so unlike the backend engine it does substitute into command, and nothing on that path runs validate_mcp_config. Simulated verbatim from its source:
plain ${VAR} in command -> {"command": "/bin/sh -c whatever", "args": []}
That is the RCE-by-config class #590 closed on the inject path — a credential value becoming the executed command. It is inert only because nothing calls the endpoint, and the security comment above actively invites someone to call it.
Note the endpoint is not unauthenticated: #1159 puts the agent server behind X-Trinity-Agent-Token, so only the backend (or the agent itself, which holds its own token) can reach it. That bounds the exposure; it does not make the substitution safe.
The decision
Pick one, don't leave it:
- Delete the endpoint and correct
files.py:181-186 + architecture.md:331 to name the path that actually exists (validated POST /api/agents/{name}/credentials/inject); or
- Wire it deliberately as the implementation of the
.mcp.json.template contract — in which case it must run validate_mcp_config on its output and must not substitute into command.
The second option overlaps with the sibling "github: templates never render .mcp.json.template" issue; if that one is fixed by rendering at creation in the backend, this endpoint should be deleted rather than revived.
Acceptance criteria
Related
Summary
POST /api/credentials/update(docker/base-image/agent_server/routers/credentials.py:74) has no callers anywhere — not the backend, not the MCP server, notstartup.sh, not the frontend, not the enterprise submodule. Its own docstring says otherwise:Two other places treat it as live:
docker/base-image/agent_server/routers/files.py:181-186names it as the sanctioned alternative to raw.mcp.jsonediting: "Owners modify MCP servers at agent-creation time via the template, or via the platform-internal/api/credentials/updateflow which regenerates .mcp.json from the template with envsubst (no arbitrary content). See SEC: PUT /api/agents/{name}/files guardrail bypass → .mcp.json write → RCE and Anthropic token exfiltration (AISEC-C2) #590 (AISEC-C2)." Both direct-edit paths are blocked on the strength of that sentence.docs/memory/architecture.md:331lists it as a live agent endpoint.So the documented escape hatch for configuring MCP servers does not exist, and it is the only implementation of the
.mcp.json.templatecontract the agent guide promises (#2007).Verification
The backend's agent-facing credential calls are
/api/credentials/status(routers/credentials.py:72),/inject(:345),/read+/list(credential_encryption.py:308,329,346),/reload-token(subscription_auto_switch.py:361).startup.sh:541calls only/api/internal/decrypt-and-inject. Every agent URL in the backend is a literal f-string with a fixed path; no dynamic construction reaches it. Searchedsrc/,docker/,scripts/,config/, the.claudesubmodule and the populatedsrc/backend/enterprisesubmodule.Why it matters if anyone re-wires it
The renderer is a whole-text
str.replaceover.mcp.json.template, so unlike the backend engine it does substitute intocommand, and nothing on that path runsvalidate_mcp_config. Simulated verbatim from its source:That is the RCE-by-config class #590 closed on the inject path — a credential value becoming the executed command. It is inert only because nothing calls the endpoint, and the security comment above actively invites someone to call it.
Note the endpoint is not unauthenticated:
#1159puts the agent server behindX-Trinity-Agent-Token, so only the backend (or the agent itself, which holds its own token) can reach it. That bounds the exposure; it does not make the substitution safe.The decision
Pick one, don't leave it:
files.py:181-186+architecture.md:331to name the path that actually exists (validatedPOST /api/agents/{name}/credentials/inject); or.mcp.json.templatecontract — in which case it must runvalidate_mcp_configon its output and must not substitute intocommand.The second option overlaps with the sibling "
github:templates never render.mcp.json.template" issue; if that one is fixed by rendering at creation in the backend, this endpoint should be deleted rather than revived.Acceptance criteria
files.py:181-186describes a path that existsdocs/memory/architecture.md:331matches realityvalidate_mcp_config, and${...}incommandis refused rather than substituted${VAR}can reachcommandthrough this pathRelated
github:templates never render.mcp.json.template— declared MCP servers are silently absent #2007 — the.mcp.json.templaterendering gap; decides delete vs wire.mcp.jsonfailed validation, and the create path consumes it #2006 — deploy-local residue, the third.mcp.jsonownership gap