You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
deploy-local validates an archive-supplied .mcp.jsonafter it has already copied the archive into /data/deployed-templates/<version_name>/ — a live member of _LOCAL_TEMPLATE_ROOTS. The 400 aborts the deploy but nothing removes the directory, so a config the validator refused stays on disk and is reachable by a subsequent POST /api/agents {"template": "local:<version_name>"}.
ent#213 closed the workspace ingress (_prepopulate_workspace_from_template). This is the template-directory ingress, which the same guard sits below.
_validate_archive_mcp_config(...) raises 400, 32 lines later
:645-649
finally removes temp_dir only — dest_path is untouched (the only other rmtree, :532, is the pre-copy overwrite)
crud.py:150-153 — _LOCAL_TEMPLATE_ROOTS includes Path("/data/deployed-templates"). deploy.py:550 — the deploy path's own create call uses template=f"local:{version_name}", i.e. exactly the id that resolves to the residue. validate_mcp_config has three call sites (routers/credentials.py:331, credential_encryption.py:71, deploy.py:74) — none on the create path.
version_name is derived from the caller's own base_name via get_next_version_name, so the residue is addressable by the person who created it.
Reproduction (executed against a real backend container, not inferred)
Baseline: /data/deployed-templates did not exist.
validator verdict : REJECTED (Server 'ebook-mcp': args[1] contains shell metacharacters)
deploy result : HTTP 400
deploy detail : Invalid .mcp.json in archive: ...
RESIDUE dir exists : True
RESIDUE contents : ['.mcp.json', 'CLAUDE.md', 'template.yaml']
_LOCAL_TEMPLATE_ROOTS : ['/agent-configs/templates', '/data/deployed-templates']
residue inside a root : True -> local:resid-probe-1929 resolves here
The rejected .mcp.json was on disk verbatim, including the ${LD_PRELOAD} reserved env-ref the validator had just refused.
Consuming it (no container created — resolver + credential writer only):
Configs rejected for reasons substitution does not touch pass through verbatim — unknown top-level field, trinity redefined as stdio, oversize. And _stage_config_files mounts the whole template directory at /template, so startup.sh copies the raw file into the agent home regardless of what the credential writer does.
Severity
Within the deploying creator's own trust boundary — same framing ent#213 itself used ("defense-in-depth / consistency, not critical RCE"). Filing P2, not as a security escalation. What it costs is the validation guarantee: a creator can get an arbitrary rejected .mcp.json onto an agent, and Claude Code auto-loads ~/.mcp.json via --mcp-config on the next execution.
Suggested fix
Validate beforecopytree (the guard only needs the extracted extract_root / ".mcp.json", which exists at that point), or remove dest_path on any failure after the copy. The first is preferable — a guard that runs after the persist is not a gate.
Acceptance criteria
A deploy whose .mcp.json fails validation leaves no directory under /data/deployed-templates/
local:<version_name> for a rejected deploy resolves to nothing (create returns the normal not-found path)
A valid deploy is unaffected — same behaviour, same 200
Regression test exercising the ordering, not the guard in isolation. ent#213's suite tests _validate_archive_mcp_config directly ("the full deploy flow needs Docker" — test_213_deploy_mcp_validation.py:17-19), which is why this ordering was never covered. The repro above mocks only the docker/db lookups preceding the window and runs the real flow, so it works without Docker.
Summary
deploy-localvalidates an archive-supplied.mcp.jsonafter it has already copied the archive into/data/deployed-templates/<version_name>/— a live member of_LOCAL_TEMPLATE_ROOTS. The 400 aborts the deploy but nothing removes the directory, so a config the validator refused stays on disk and is reachable by a subsequentPOST /api/agents {"template": "local:<version_name>"}.ent#213 closed the workspace ingress (
_prepopulate_workspace_from_template). This is the template-directory ingress, which the same guard sits below.The ordering
services/agent_service/deploy.py:534shutil.copytree(extract_root, dest_path)→/data/deployed-templates/<version_name>/:566-571_validate_archive_mcp_config(...)raises 400, 32 lines later:645-649finallyremovestemp_dironly —dest_pathis untouched (the only otherrmtree,:532, is the pre-copy overwrite)crud.py:150-153—_LOCAL_TEMPLATE_ROOTSincludesPath("/data/deployed-templates").deploy.py:550— the deploy path's own create call usestemplate=f"local:{version_name}", i.e. exactly the id that resolves to the residue.validate_mcp_confighas three call sites (routers/credentials.py:331,credential_encryption.py:71,deploy.py:74) — none on the create path.version_nameis derived from the caller's ownbase_nameviaget_next_version_name, so the residue is addressable by the person who created it.Reproduction (executed against a real backend container, not inferred)
Baseline:
/data/deployed-templatesdid not exist.The rejected
.mcp.jsonwas on disk verbatim, including the${LD_PRELOAD}reserved env-ref the validator had just refused.Consuming it (no container created — resolver + credential writer only):
Two things worth noting from that last line:
.mcp.jsonplaceholder handling contradicts the platform validator (${VAR:-default}blanked,commandunsubstituted, output never validated) #1929's${VAR:-default}→""blanking removed the very characters the validator rejected, so the generated config passes cleanly. Any fix of the shape "validate the generated output" would go green on this case.trinityredefined as stdio, oversize. And_stage_config_filesmounts the whole template directory at/template, sostartup.shcopies the raw file into the agent home regardless of what the credential writer does.Severity
Within the deploying creator's own trust boundary — same framing ent#213 itself used ("defense-in-depth / consistency, not critical RCE"). Filing P2, not as a security escalation. What it costs is the validation guarantee: a creator can get an arbitrary rejected
.mcp.jsononto an agent, and Claude Code auto-loads~/.mcp.jsonvia--mcp-configon the next execution.Suggested fix
Validate before
copytree(the guard only needs the extractedextract_root / ".mcp.json", which exists at that point), or removedest_pathon any failure after the copy. The first is preferable — a guard that runs after the persist is not a gate.Acceptance criteria
.mcp.jsonfails validation leaves no directory under/data/deployed-templates/local:<version_name>for a rejected deploy resolves to nothing (create returns the normal not-found path)_validate_archive_mcp_configdirectly ("the full deploy flow needs Docker" —test_213_deploy_mcp_validation.py:17-19), which is why this ordering was never covered. The repro above mocks only the docker/db lookups preceding the window and runs the real flow, so it works without Docker.Related
.mcp.jsonplaceholder handling contradicts the platform validator (${VAR:-default}blanked,commandunsubstituted, output never validated) #1929 — the create path's own lack of a contract; this issue is that defect's only live route.mcp.jsoningress classgithub:templates never render.mcp.json.template— declared MCP servers are silently absent #2007,POST /api/credentials/updatehas no callers but is named as the sanctioned MCP-config path #2008 — the other two.mcp.jsonownership gaps found in the same pass